patchwarden 1.5.0 → 1.5.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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -255,6 +255,12 @@ html.dark {
255
255
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
256
256
  <span>Refresh</span>
257
257
  </button>
258
+ <div class="flex items-center gap-2 px-3 py-1.5 rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border); font-family: var(--pw-font-sans);">
259
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--pw-text-tertiary);"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2z"/></svg>
260
+ <select id="repo-selector" class="bg-transparent text-sm outline-none cursor-pointer" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans); min-width: 180px;">
261
+ <option value=".">workspace root</option>
262
+ </select>
263
+ </div>
258
264
  <div class="flex-1 min-w-0"></div>
259
265
  <button data-action="open-core-tunnel" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: transparent; color: var(--pw-accent); border: 1px solid var(--pw-accent); font-family: var(--pw-font-sans);">
260
266
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
@@ -273,6 +279,17 @@ html.dark {
273
279
  <!-- ===== ERROR BANNER ===== -->
274
280
  <div id="error-banner" class="hidden mb-4 rounded-lg px-4 py-3 text-sm" style="background-color: var(--pw-state-error-bg); border: 1px solid var(--pw-state-error); color: var(--pw-state-error); font-family: var(--pw-font-sans);"></div>
275
281
 
282
+ <!-- ===== HEALTH SCORE CARD ===== -->
283
+ <div id="health-score-card" class="rounded-lg mb-4" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
284
+ <div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
285
+ <h2 class="text-sm font-semibold truncate" data-zh="健康评分" data-en="Health Score" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans);">健康评分</h2>
286
+ <span id="health-score-badge" class="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap" style="color: var(--pw-text-tertiary); background-color: var(--pw-bg-hover);">—</span>
287
+ </div>
288
+ <div id="health-score-body" class="px-4 py-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
289
+ <div class="text-xs" style="color: var(--pw-text-tertiary);">loading…</div>
290
+ </div>
291
+ </div>
292
+
276
293
  <!-- ===== SETUP CHECKLIST ===== -->
277
294
  <div id="setup-checklist-card" class="rounded-lg mb-4" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
278
295
  <div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
@@ -336,6 +353,15 @@ html.dark {
336
353
  </div>
337
354
  </div>
338
355
 
356
+ <!-- ===== STALE TASKS CARD ===== -->
357
+ <div id="stale-tasks-card" class="hidden rounded-lg mb-6" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border); border-left: 3px solid var(--pw-state-warning);">
358
+ <div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
359
+ <h2 class="text-sm font-semibold truncate" data-zh="Stale 任务" data-en="Stale Tasks" style="color: var(--pw-text-primary);">Stale 任务</h2>
360
+ <span id="stale-tasks-count" class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">—</span>
361
+ </div>
362
+ <div id="stale-tasks-body" class="px-4 py-3 text-sm" style="color: var(--pw-text-tertiary);">Loading</div>
363
+ </div>
364
+
339
365
  <!-- ===== TWO-COLUMN LAYOUT ===== -->
340
366
  <div class="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-6">
341
367
 
@@ -352,8 +378,12 @@ html.dark {
352
378
 
353
379
  <!-- RIGHT COLUMN: 系统状态 (2/5 width) -->
354
380
  <div class="lg:col-span-2 rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
355
- <div class="px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
381
+ <div class="flex items-center justify-between gap-2 px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
356
382
  <h2 class="text-sm font-semibold truncate" data-zh="系统状态" data-en="System Status" style="color: var(--pw-text-primary);">系统状态</h2>
383
+ <button id="btn-copy-diagnostics" class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle); font-family: var(--pw-font-sans);" type="button" title="Copy diagnostics to clipboard">
384
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
385
+ <span data-zh="复制诊断" data-en="Copy diagnostics">复制诊断</span>
386
+ </button>
357
387
  </div>
358
388
  <div id="system-status-body" class="px-4 py-2">
359
389
  <div class="py-3 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
@@ -384,6 +414,17 @@ html.dark {
384
414
  </div>
385
415
  </div>
386
416
 
417
+ <!-- ===== SAFE MODAL ===== -->
418
+ <div id="safe-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: rgba(0,0,0,0.6);">
419
+ <div class="rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border); box-shadow: var(--pw-shadow-modal);">
420
+ <div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
421
+ <h3 id="safe-modal-title" class="text-sm font-semibold truncate" style="color: var(--pw-text-primary);">—</h3>
422
+ <button id="safe-modal-close" class="text-lg leading-none" style="color: var(--pw-text-tertiary);" type="button">×</button>
423
+ </div>
424
+ <div id="safe-modal-body" class="flex-1 overflow-auto px-4 py-3 text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono); white-space: pre-wrap; word-break: break-all;"></div>
425
+ </div>
426
+ </div>
427
+
387
428
  </main>
388
429
 
389
430
  </div>
@@ -477,6 +518,44 @@ html.dark {
477
518
  @keyframes pw-toast-out {
478
519
  to { opacity: 0; transform: translateX(20px); }
479
520
  }
521
+ /* Quick action icon buttons */
522
+ .pw-quick-btn {
523
+ display: inline-flex;
524
+ align-items: center;
525
+ justify-content: center;
526
+ width: 24px;
527
+ height: 24px;
528
+ border-radius: 4px;
529
+ border: 1px solid var(--pw-border-subtle);
530
+ background-color: var(--pw-bg-hover);
531
+ color: var(--pw-text-secondary);
532
+ cursor: pointer;
533
+ transition: background-color var(--pw-transition-fast), color var(--pw-transition-fast);
534
+ text-decoration: none;
535
+ }
536
+ .pw-quick-btn:hover {
537
+ background-color: var(--pw-bg-active);
538
+ color: var(--pw-text-primary);
539
+ opacity: 1;
540
+ }
541
+ /* Acceptance status badge - more prominent than status */
542
+ .pw-acceptance-badge {
543
+ font-size: var(--pw-text-sm);
544
+ font-weight: var(--pw-weight-bold);
545
+ padding: 2px 8px;
546
+ border-width: 1px;
547
+ border-style: solid;
548
+ }
549
+ /* Stale task explanation - prominent */
550
+ .pw-stale-explanation {
551
+ font-size: var(--pw-text-sm);
552
+ font-weight: var(--pw-weight-medium);
553
+ color: var(--pw-text-primary);
554
+ padding: 6px 10px;
555
+ border-radius: var(--pw-radius-sm);
556
+ background-color: var(--pw-state-warning-bg);
557
+ border-left: 2px solid var(--pw-state-warning);
558
+ }
480
559
  </style>
481
560
 
482
561
  <script>
@@ -485,6 +564,7 @@ html.dark {
485
564
 
486
565
  let controlToken = null;
487
566
  let statusTimer = null;
567
+ let currentRepoPath = ".";
488
568
 
489
569
  // ---------- Utilities ----------
490
570
  function $(id) { return document.getElementById(id); }
@@ -878,6 +958,155 @@ html.dark {
878
958
  }
879
959
  }
880
960
 
961
+ // ---------- Clipboard + Safe Modal ----------
962
+ function copyToClipboard(text, label) {
963
+ if (!text) return;
964
+ if (navigator.clipboard && navigator.clipboard.writeText) {
965
+ navigator.clipboard.writeText(text).then(function () {
966
+ showToast('success', '已复制', (label || '内容') + '已复制到剪贴板');
967
+ }).catch(function () {
968
+ showToast('error', '复制失败', '请手动复制');
969
+ });
970
+ } else {
971
+ showToast('warning', '复制不支持', '浏览器不支持剪贴板 API');
972
+ }
973
+ }
974
+
975
+ // Format diagnostics response as plain text for clipboard.
976
+ function formatDiagnosticsText(d) {
977
+ if (!d || typeof d !== 'object') return '';
978
+ const lines = [];
979
+ lines.push('PatchWarden Diagnostics');
980
+ lines.push('======================');
981
+ lines.push('server_version: ' + (d.server_version != null ? d.server_version : '—'));
982
+ lines.push('schema_epoch: ' + (d.schema_epoch != null ? d.schema_epoch : '—'));
983
+ lines.push('tool_manifest_sha256: ' + (d.tool_manifest_sha256 != null ? d.tool_manifest_sha256 : '—'));
984
+ lines.push('watcher_status: ' + (d.watcher_status != null ? d.watcher_status : '—'));
985
+ lines.push('tunnel_core_ready: ' + (d.tunnel_core_ready === true ? 'true' : 'false'));
986
+ lines.push('tunnel_direct_ready: ' + (d.tunnel_direct_ready === true ? 'true' : 'false'));
987
+ lines.push('core_tool_count: ' + (d.core_tool_count != null ? d.core_tool_count : '—'));
988
+ lines.push('direct_tool_count: ' + (d.direct_tool_count != null ? d.direct_tool_count : '—'));
989
+ lines.push('direct_profile_enabled: ' + (d.direct_profile_enabled === true ? 'true' : 'false'));
990
+ lines.push('workspace_root: ' + (d.workspace_root != null ? d.workspace_root : '—'));
991
+ lines.push('');
992
+ const agents = Array.isArray(d.agent_status) ? d.agent_status : [];
993
+ lines.push('Agents:');
994
+ if (agents.length === 0) {
995
+ lines.push(' (none)');
996
+ } else {
997
+ agents.forEach(function (a) {
998
+ lines.push(' - ' + (a.name || '?') + ': ' + (a.available ? 'available' : 'unavailable'));
999
+ });
1000
+ }
1001
+ lines.push('');
1002
+ const failures = Array.isArray(d.recent_failures) ? d.recent_failures : [];
1003
+ lines.push('Recent failures:');
1004
+ if (failures.length === 0) {
1005
+ lines.push(' (none)');
1006
+ } else {
1007
+ failures.forEach(function (f) {
1008
+ lines.push(' - task_id: ' + (f.task_id || '?') + ', status: ' + (f.status || '?'));
1009
+ });
1010
+ }
1011
+ return lines.join('\n');
1012
+ }
1013
+
1014
+ async function copyDiagnostics(btnEl) {
1015
+ const orig = btnEl ? btnEl.innerHTML : null;
1016
+ if (btnEl) {
1017
+ btnEl.disabled = true;
1018
+ btnEl.innerHTML =
1019
+ '<span class="inline-block w-3 h-3 align-middle border-2 border-current border-t-transparent rounded-full pw-spin"></span>'
1020
+ + '<span class="ml-1.5">处理中…</span>';
1021
+ }
1022
+ try {
1023
+ const data = await fetchJson('/api/diagnostics');
1024
+ const text = formatDiagnosticsText(data);
1025
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1026
+ await navigator.clipboard.writeText(text);
1027
+ showToast('success', '诊断信息已复制到剪贴板', 'PatchWarden Diagnostics 已复制到剪贴板');
1028
+ } else {
1029
+ showToast('warning', '复制不支持', '浏览器不支持剪贴板 API');
1030
+ }
1031
+ } catch (err) {
1032
+ showToast('error', '诊断复制失败', err.message);
1033
+ } finally {
1034
+ if (btnEl) {
1035
+ btnEl.disabled = false;
1036
+ btnEl.innerHTML = orig;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ function showSafeModal(title, data) {
1042
+ const modal = $('safe-modal');
1043
+ const titleEl = $('safe-modal-title');
1044
+ const bodyEl = $('safe-modal-body');
1045
+ if (!modal || !titleEl || !bodyEl) return;
1046
+ titleEl.textContent = title || '—';
1047
+ let content;
1048
+ if (data && typeof data === 'object') {
1049
+ content = JSON.stringify(data, null, 2);
1050
+ } else {
1051
+ content = String(data || '');
1052
+ }
1053
+ bodyEl.textContent = content;
1054
+ modal.classList.remove('hidden');
1055
+ }
1056
+
1057
+ function closeSafeModal() {
1058
+ const modal = $('safe-modal');
1059
+ if (modal) modal.classList.add('hidden');
1060
+ }
1061
+
1062
+ async function fetchSafeSummary(taskId, kind, title) {
1063
+ if (!taskId) return;
1064
+ try {
1065
+ const data = await fetchJson('/api/tasks/' + encodeURIComponent(taskId) + '/' + kind);
1066
+ showSafeModal(title, data);
1067
+ } catch (err) {
1068
+ showError('加载 ' + title + ' 失败: ' + err.message);
1069
+ }
1070
+ }
1071
+
1072
+ // ---------- Task status priority ----------
1073
+ function primaryStatus(task) {
1074
+ if (!task) return { key: '', label: '—', kind: 'neutral' };
1075
+ const priority = [
1076
+ 'failed_policy_violation',
1077
+ 'failed_scope_violation',
1078
+ 'failed_verification',
1079
+ 'needs_fix',
1080
+ 'ready_for_review',
1081
+ 'done_by_agent',
1082
+ 'running',
1083
+ 'pending'
1084
+ ];
1085
+ const status = task.status || '';
1086
+ const acceptance = task.acceptance_status || '';
1087
+ const statusIdx = priority.indexOf(status);
1088
+ const acceptanceIdx = priority.indexOf(acceptance);
1089
+ let chosen = '';
1090
+ if (statusIdx >= 0 && acceptanceIdx >= 0) {
1091
+ chosen = statusIdx <= acceptanceIdx ? status : acceptance;
1092
+ } else if (statusIdx >= 0) {
1093
+ chosen = status;
1094
+ } else if (acceptanceIdx >= 0) {
1095
+ chosen = acceptance;
1096
+ } else {
1097
+ chosen = acceptance || status || '';
1098
+ }
1099
+ return { key: chosen, label: chosen || '—', kind: statusKind(chosen || status) };
1100
+ }
1101
+
1102
+ function verificationHeadline(t) {
1103
+ if (!t) return '—';
1104
+ if (t.verification && t.verification.headline) return t.verification.headline;
1105
+ if (t.safe_result && t.safe_result.verification && t.safe_result.verification.headline) return t.safe_result.verification.headline;
1106
+ if (t.verification_headline) return t.verification_headline;
1107
+ return '—';
1108
+ }
1109
+
881
1110
  // ---------- Render: Service Card ----------
882
1111
  function renderServiceCard(prefix, label, status) {
883
1112
  const cardEl = $('card-' + prefix);
@@ -1000,21 +1229,77 @@ html.dark {
1000
1229
  bodyEl.innerHTML = '<div class="px-4 py-6 text-center text-sm" style="color: var(--pw-text-tertiary);">暂无任务</div>';
1001
1230
  return;
1002
1231
  }
1232
+ const titleMap = {
1233
+ 'safe-result': 'Safe Result',
1234
+ 'safe-audit': 'Safe Audit',
1235
+ 'safe-test-summary': 'Safe Test Summary',
1236
+ 'safe-diff-summary': 'Safe Diff Summary'
1237
+ };
1003
1238
  let html = '';
1004
1239
  top.forEach(function (t) {
1005
- const kind = statusKind(t.status);
1006
- const c = statusColors(kind);
1007
- const href = '/pages/task-detail.html?id=' + encodeURIComponent(t.task_id || '');
1240
+ const taskId = t.task_id || '';
1241
+ const href = '/pages/task-detail.html?id=' + encodeURIComponent(taskId);
1242
+ const sKind = statusKind(t.status);
1243
+ const sColor = statusColors(sKind);
1244
+ const aKind = statusKind(t.acceptance_status);
1245
+ const aColor = statusColors(aKind);
1246
+ // Skip status badge when redundant with acceptance (done + done_by_agent)
1247
+ const skipStatusBadge = (t.status === 'done' && t.acceptance_status === 'done_by_agent');
1248
+ const headline = verificationHeadline(t);
1249
+ const updated = t.updated_at || t.created_at || '';
1250
+
1008
1251
  html +=
1009
- '<a href="' + href + '" data-task-row class="flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer" style="border-bottom: 1px solid var(--pw-border-subtle);">'
1010
- + '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap" style="color: var(--pw-text-secondary); background-color: var(--pw-bg-hover); font-family: var(--pw-font-mono);" title="' + escapeHtml(t.task_id || '') + '">' + escapeHtml(shortId(t.task_id)) + '</span>'
1011
- + '<span class="flex-1 min-w-0 text-sm truncate" style="color: var(--pw-text-primary);" title="' + escapeHtml(t.title || '') + '">' + escapeHtml(t.title || '—') + '</span>'
1012
- + '<span class="shrink-0 text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);" title="phase">' + escapeHtml(t.phase || '') + '</span>'
1013
- + '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style="color: ' + c.color + '; background-color: ' + c.bg + ';">' + escapeHtml(t.status || '—') + '</span>'
1014
- + '<span class="shrink-0 text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">' + escapeHtml(fmtTime(t.created_at)) + '</span>'
1015
- + '</a>';
1252
+ '<div data-task-row class="px-4 py-3 transition-colors" style="border-bottom: 1px solid var(--pw-border-subtle);">'
1253
+ + '<div class="flex items-center gap-2 flex-wrap">'
1254
+ // short task_id + copy button
1255
+ + '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap" style="color: var(--pw-text-secondary); background-color: var(--pw-bg-hover); font-family: var(--pw-font-mono);" title="' + escapeHtml(taskId) + '">' + escapeHtml(shortId(taskId)) + '</span>'
1256
+ + '<button class="pw-quick-btn" data-copy-id="' + escapeHtml(taskId) + '" title="复制 task_id" type="button">'
1257
+ + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
1258
+ + '</button>'
1259
+ // title (truncated, link to detail)
1260
+ + '<a href="' + href + '" class="flex-1 min-w-0 text-sm truncate hover:underline" style="color: var(--pw-text-primary);" title="' + escapeHtml(t.title || '') + '">' + escapeHtml(t.title || '—') + '</a>'
1261
+ // agent badge
1262
+ + (t.agent ? badgeHtml(t.agent, 'info') : '')
1263
+ // status badge (skip if redundant)
1264
+ + (skipStatusBadge ? '' : '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style="color: ' + sColor.color + '; background-color: ' + sColor.bg + ';">' + escapeHtml(t.status || '—') + '</span>')
1265
+ // acceptance badge (more prominent)
1266
+ + (t.acceptance_status ? '<span class="shrink-0 pw-acceptance-badge rounded-full whitespace-nowrap" style="color: ' + aColor.color + '; background-color: ' + aColor.bg + '; border-color: ' + aColor.color + ';">' + escapeHtml(t.acceptance_status) + '</span>' : '')
1267
+ + '</div>'
1268
+ // second line: repo_path, verification headline, updated_at, next_action
1269
+ + '<div class="flex items-center gap-3 mt-1.5 flex-wrap">'
1270
+ + '<span class="text-xs truncate" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono); max-width: 200px;" title="' + escapeHtml(t.repo_path || '') + '">' + escapeHtml(t.repo_path || '—') + '</span>'
1271
+ + '<span class="text-xs truncate" style="color: var(--pw-text-secondary); max-width: 280px;" title="' + escapeHtml(headline) + '">verify: ' + escapeHtml(headline) + '</span>'
1272
+ + '<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">' + escapeHtml(fmtTime(updated)) + '</span>'
1273
+ + (t.next_action ? '<span class="text-xs truncate" style="color: var(--pw-text-tertiary); max-width: 200px;" title="' + escapeHtml(t.next_action) + '">next: ' + escapeHtml(t.next_action) + '</span>' : '')
1274
+ + '</div>'
1275
+ // quick action buttons
1276
+ + '<div class="flex items-center gap-1 mt-1.5">'
1277
+ + '<button class="pw-quick-btn" data-safe-action="safe-result" data-task-id="' + escapeHtml(taskId) + '" title="safe result" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></button>'
1278
+ + '<button class="pw-quick-btn" data-safe-action="safe-audit" data-task-id="' + escapeHtml(taskId) + '" title="safe audit" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></button>'
1279
+ + '<button class="pw-quick-btn" data-safe-action="safe-test-summary" data-task-id="' + escapeHtml(taskId) + '" title="safe test summary" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></button>'
1280
+ + '<button class="pw-quick-btn" data-safe-action="safe-diff-summary" data-task-id="' + escapeHtml(taskId) + '" title="safe diff summary" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg></button>'
1281
+ + '<a href="' + href + '" class="pw-quick-btn" title="打开详情"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>'
1282
+ + '</div>'
1283
+ + '</div>';
1016
1284
  });
1017
1285
  bodyEl.innerHTML = html;
1286
+ // Wire quick action buttons
1287
+ bodyEl.querySelectorAll('[data-safe-action]').forEach(function (btn) {
1288
+ btn.addEventListener('click', function (e) {
1289
+ e.preventDefault();
1290
+ e.stopPropagation();
1291
+ const action = btn.getAttribute('data-safe-action');
1292
+ const tid = btn.getAttribute('data-task-id');
1293
+ fetchSafeSummary(tid, action, titleMap[action] || action);
1294
+ });
1295
+ });
1296
+ bodyEl.querySelectorAll('[data-copy-id]').forEach(function (btn) {
1297
+ btn.addEventListener('click', function (e) {
1298
+ e.preventDefault();
1299
+ e.stopPropagation();
1300
+ copyToClipboard(btn.getAttribute('data-copy-id'), 'task_id');
1301
+ });
1302
+ });
1018
1303
  }
1019
1304
 
1020
1305
  // ---------- Render: Activity Log ----------
@@ -1069,49 +1354,341 @@ html.dark {
1069
1354
  const lineages = Array.isArray(data.lineages) ? data.lineages : [];
1070
1355
  if (count) count.textContent = String(data.total || lineages.length);
1071
1356
  if (!lineages.length) {
1072
- body.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">No loop lineage yet</div>';
1357
+ body.innerHTML =
1358
+ '<div class="text-xs mb-2" style="color: var(--pw-text-tertiary);">No loop lineage yet</div>'
1359
+ + '<div class="flex items-center gap-2">'
1360
+ + '<a href="/tasks.html" class="text-xs px-2.5 py-1 rounded whitespace-nowrap" style="background-color: var(--pw-accent-muted); color: var(--pw-accent); border: 1px solid var(--pw-accent);">Start guarded loop</a>'
1361
+ + '<a href="/tasks.html" class="text-xs px-2.5 py-1 rounded whitespace-nowrap" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">View recent loop runs</a>'
1362
+ + '</div>';
1073
1363
  return;
1074
1364
  }
1075
1365
  const top = lineages[0];
1076
- const directSessions = top.tasks && Array.isArray(top.tasks.direct_sessions) ? top.tasks.direct_sessions : [];
1077
- const direct = directSessions.length ? directSessions[0] : null;
1078
- const worktree = top.worktree || {};
1079
- const directStatus = direct
1080
- ? ((direct.status || 'unknown') + ' / audit ' + (direct.audit_decision || 'not_run'))
1366
+ const dv = top.direct_verification || null;
1367
+ const directStatus = dv
1368
+ ? (escapeHtml(dv.status || 'unknown') + ' / audit ' + escapeHtml(dv.audit_decision || 'not_run'))
1081
1369
  : 'not run';
1370
+ const mainCount = top.main_task_count != null ? top.main_task_count : (top.tasks && top.tasks.main ? 1 : 0);
1371
+ const fixCount = top.fix_task_count != null ? top.fix_task_count : (top.tasks && Array.isArray(top.tasks.fix) ? top.tasks.fix.length : 0);
1372
+ const cleanupCount = top.cleanup_task_count != null ? top.cleanup_task_count : (top.tasks && Array.isArray(top.tasks.cleanup) ? top.tasks.cleanup.length : 0);
1373
+ const iterCount = top.iterations != null ? top.iterations : (Array.isArray(top.rounds) ? top.rounds.length : 0);
1374
+ const warnCount = top.warnings_count != null ? top.warnings_count : (Array.isArray(top.warnings) ? top.warnings.length : 0);
1082
1375
  body.innerHTML =
1083
1376
  '<div class="flex items-center justify-between gap-2 mb-2">'
1084
1377
  + '<span class="text-xs truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(top.lineage_id || '') + '">' + escapeHtml(shortId(top.lineage_id)) + '</span>'
1085
- + badgeHtml(top.stop_reason || 'unknown', statusKind(top.final_status))
1378
+ + badgeHtml(top.final_status || 'unknown', statusKind(top.final_status))
1086
1379
  + '</div>'
1087
1380
  + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-secondary);" title="' + escapeHtml(top.goal || '') + '">' + escapeHtml(top.goal || 'No goal') + '</div>'
1088
- + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-tertiary);">Isolation: ' + escapeHtml(worktree.isolation_mode || 'current_repo') + '</div>'
1089
- + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-tertiary);" title="' + escapeHtml(direct && direct.session_id ? direct.session_id : '') + '">Direct: ' + escapeHtml(directStatus) + '</div>'
1090
- + '<div class="text-xs" style="color: var(--pw-text-tertiary);">Next: ' + escapeHtml(top.next_action || 'none') + '</div>';
1381
+ + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-tertiary);">stop_reason: ' + escapeHtml(top.stop_reason || 'unknown') + '</div>'
1382
+ + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-tertiary);">iterations: ' + escapeHtml(String(iterCount)) + ' · main/fix/cleanup: ' + escapeHtml(String(mainCount)) + '/' + escapeHtml(String(fixCount)) + '/' + escapeHtml(String(cleanupCount)) + '</div>'
1383
+ + '<div class="text-xs truncate mb-1" style="color: var(--pw-text-tertiary);" title="' + escapeHtml(dv && dv.session_id ? dv.session_id : '') + '">direct: ' + directStatus + '</div>'
1384
+ + '<div class="text-xs mb-2" style="color: var(--pw-text-tertiary);">warnings: ' + escapeHtml(String(warnCount)) + '</div>'
1385
+ + '<div class="flex items-center gap-2">'
1386
+ + '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-lineage-detail="' + escapeHtml(top.lineage_id || '') + '" type="button" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">View detail</button>'
1387
+ + '<button class="pw-quick-btn" data-copy-id="' + escapeHtml(top.lineage_id || '') + '" title="复制 lineage_id" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
1388
+ + '</div>';
1389
+ // Wire lineage detail button
1390
+ const detailBtn = body.querySelector('[data-lineage-detail]');
1391
+ if (detailBtn) {
1392
+ detailBtn.addEventListener('click', function () {
1393
+ const lid = detailBtn.getAttribute('data-lineage-detail');
1394
+ if (lid) showLineageDetail(lid);
1395
+ });
1396
+ }
1397
+ // Wire copy button
1398
+ const copyBtn = body.querySelector('[data-copy-id]');
1399
+ if (copyBtn) {
1400
+ copyBtn.addEventListener('click', function (e) {
1401
+ e.preventDefault();
1402
+ e.stopPropagation();
1403
+ copyToClipboard(copyBtn.getAttribute('data-copy-id'), 'lineage_id');
1404
+ });
1405
+ }
1091
1406
  } catch (err) {
1092
1407
  if (count) count.textContent = 'error';
1093
1408
  body.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
1094
1409
  }
1095
1410
  }
1096
1411
 
1412
+ async function showLineageDetail(lineageId) {
1413
+ if (!lineageId) return;
1414
+ const modal = $('safe-modal');
1415
+ const titleEl = $('safe-modal-title');
1416
+ const bodyEl = $('safe-modal-body');
1417
+ if (!modal || !titleEl || !bodyEl) return;
1418
+ titleEl.textContent = 'Lineage Detail · ' + shortId(lineageId);
1419
+ bodyEl.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">Loading…</div>';
1420
+ modal.classList.remove('hidden');
1421
+ try {
1422
+ const data = await fetchJson('/api/lineages/' + encodeURIComponent(lineageId));
1423
+ bodyEl.innerHTML = renderLineageDetail(data);
1424
+ } catch (err) {
1425
+ bodyEl.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
1426
+ }
1427
+ }
1428
+
1429
+ function renderLineageDetail(d) {
1430
+ if (!d) return '<div class="text-xs" style="color: var(--pw-text-tertiary);">No data</div>';
1431
+ const rounds = Array.isArray(d.rounds) ? d.rounds : [];
1432
+ const mainRounds = rounds.filter(function (r) { return r.role === 'main'; });
1433
+ const fixRounds = rounds.filter(function (r) { return r.role === 'fix_tests'; });
1434
+ const cleanupRounds = rounds.filter(function (r) { return r.role === 'cleanup'; });
1435
+ const directSessions = (d.tasks && Array.isArray(d.tasks.direct_sessions)) ? d.tasks.direct_sessions : [];
1436
+
1437
+ function roundRow(r) {
1438
+ return '<div class="py-1.5" style="border-bottom: 1px solid var(--pw-border-subtle);">'
1439
+ + '<div class="flex items-center gap-2 mb-1 flex-wrap">'
1440
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(r.task_id || '') + '">' + escapeHtml(shortId(r.task_id)) + '</span>'
1441
+ + badgeHtml(r.status || '—', statusKind(r.status))
1442
+ + '<span class="text-xs" style="color: var(--pw-text-tertiary);">iter ' + escapeHtml(String(r.iteration || 0)) + '</span>'
1443
+ + '</div>'
1444
+ + '<div class="text-xs mb-0.5" style="color: var(--pw-text-secondary);">verification: ' + escapeHtml(r.verification_status || '—') + '</div>'
1445
+ + '<div class="text-xs mb-0.5" style="color: var(--pw-text-secondary);">audit: ' + escapeHtml(r.audit_verdict || '—') + '</div>'
1446
+ + (r.next_action ? '<div class="text-xs" style="color: var(--pw-text-tertiary);">next: ' + escapeHtml(r.next_action) + '</div>' : '')
1447
+ + '</div>';
1448
+ }
1449
+
1450
+ function directRow(s) {
1451
+ return '<div class="py-1.5" style="border-bottom: 1px solid var(--pw-border-subtle);">'
1452
+ + '<div class="flex items-center gap-2 mb-1 flex-wrap">'
1453
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(s.session_id || '') + '">' + escapeHtml(shortId(s.session_id)) + '</span>'
1454
+ + badgeHtml(s.status || 'unknown', statusKind(s.status))
1455
+ + '</div>'
1456
+ + '<div class="text-xs mb-0.5" style="color: var(--pw-text-secondary);">audit: ' + escapeHtml(s.audit_decision || 'not_run') + '</div>'
1457
+ + '<div class="text-xs mb-0.5" style="color: var(--pw-text-secondary);">commands: ' + escapeHtml(String(s.command_count || 0)) + ' (passed ' + escapeHtml(String(s.passed_commands || 0)) + ' / failed ' + escapeHtml(String(s.failed_commands || 0)) + ')</div>'
1458
+ + (s.next_action ? '<div class="text-xs" style="color: var(--pw-text-tertiary);">next: ' + escapeHtml(s.next_action) + '</div>' : '')
1459
+ + '</div>';
1460
+ }
1461
+
1462
+ function section(title, items, renderFn) {
1463
+ if (!items || items.length === 0) return '';
1464
+ return '<div class="mt-3">'
1465
+ + '<div class="text-xs font-semibold mb-1" style="color: var(--pw-text-primary);">' + escapeHtml(title) + ' (' + items.length + ')</div>'
1466
+ + items.map(renderFn).join('')
1467
+ + '</div>';
1468
+ }
1469
+
1470
+ const header =
1471
+ '<div class="mb-2">'
1472
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-secondary);"><span style="color: var(--pw-text-tertiary);">goal:</span> ' + escapeHtml(d.goal || '—') + '</div>'
1473
+ + '<div class="flex items-center gap-2 mb-1 flex-wrap">'
1474
+ + '<span class="text-xs" style="color: var(--pw-text-tertiary);">final_status:</span>' + badgeHtml(d.final_status || 'unknown', statusKind(d.final_status))
1475
+ + '<span class="text-xs" style="color: var(--pw-text-tertiary);">stop_reason:</span><span class="text-xs" style="color: var(--pw-text-secondary);">' + escapeHtml(d.stop_reason || '—') + '</span>'
1476
+ + '</div>'
1477
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">iterations: ' + escapeHtml(String(d.iterations != null ? d.iterations : rounds.length)) + ' · warnings: ' + escapeHtml(String(d.warnings_count != null ? d.warnings_count : (Array.isArray(d.warnings) ? d.warnings.length : 0))) + '</div>'
1478
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">main: ' + escapeHtml(String(d.main_task_count != null ? d.main_task_count : 0)) + ' · fix: ' + escapeHtml(String(d.fix_task_count != null ? d.fix_task_count : 0)) + ' · cleanup: ' + escapeHtml(String(d.cleanup_task_count != null ? d.cleanup_task_count : 0)) + '</div>'
1479
+ + '<div class="text-xs" style="color: var(--pw-text-tertiary);">final_recommended_next_action: ' + escapeHtml(d.next_action || '—') + '</div>'
1480
+ + '</div>';
1481
+
1482
+ return header
1483
+ + section('Main Tasks', mainRounds, roundRow)
1484
+ + section('Fix Tasks', fixRounds, roundRow)
1485
+ + section('Cleanup Tasks', cleanupRounds, roundRow)
1486
+ + section('Direct Verification', directSessions, directRow);
1487
+ }
1488
+
1489
+ // ---------- Health Score ----------
1490
+ // Computes a health score from the /api/status payload and supplementary data.
1491
+ // Returns: { level: 'healthy'|'warning'|'degraded'|'blocked', dimensions: [{key,label,ok,detail}] }
1492
+ function calculateHealthScore(status, opts) {
1493
+ opts = opts || {};
1494
+ const staleCount = typeof opts.staleTaskCount === 'number' ? opts.staleTaskCount : 0;
1495
+ const failedCount = typeof opts.failedTaskCount === 'number' ? opts.failedTaskCount : 0;
1496
+ const policyValid = opts.policyValid === true;
1497
+ const releaseReady = opts.releaseReady === true;
1498
+ const directProfileEnabled = opts.directProfileEnabled === true;
1499
+
1500
+ const watcher = (status && status.watcher) || {};
1501
+ const tunnel = (status && status.tunnel) || {};
1502
+ const agents = (status && status.agents) || [];
1503
+
1504
+ const watcherHealthy = watcher.status === 'healthy';
1505
+ const coreReady = !!(tunnel.core && tunnel.core.ready);
1506
+ const directReady = !!(tunnel.direct && tunnel.direct.ready);
1507
+ const tunnelReady = coreReady && directReady;
1508
+ const agentsAvailable = Array.isArray(agents) && agents.some(function (a) { return a && a.available; });
1509
+
1510
+ const dimensions = [
1511
+ { key: 'watcher_healthy', label: 'Watcher healthy', ok: watcherHealthy, detail: watcher.status || 'unknown' },
1512
+ { key: 'tunnel_ready', label: 'Tunnel ready', ok: tunnelReady, detail: 'core ' + (coreReady ? '✓' : '✗') + ' / direct ' + (directReady ? '✓' : '✗') },
1513
+ { key: 'agents_available', label: 'Agents available', ok: agentsAvailable, detail: Array.isArray(agents) ? agents.length + ' agent(s)' : '0' },
1514
+ { key: 'stale_tasks', label: 'Stale tasks', ok: staleCount === 0, detail: String(staleCount) },
1515
+ { key: 'failed_tasks', label: 'Failed tasks', ok: failedCount === 0, detail: String(failedCount) },
1516
+ { key: 'policy_valid', label: 'Policy valid', ok: policyValid, detail: policyValid ? 'valid' : 'invalid' },
1517
+ { key: 'release_ready', label: 'Release ready', ok: releaseReady, detail: opts.releaseReadyState || 'unknown' },
1518
+ { key: 'direct_profile', label: 'Direct profile', ok: directProfileEnabled, detail: directProfileEnabled ? 'enabled' : 'disabled' }
1519
+ ];
1520
+
1521
+ // Scoring logic (order: blocked > degraded > warning > healthy)
1522
+ let level = 'healthy';
1523
+ const watcherDownAndFailed = (!watcherHealthy && failedCount > 0);
1524
+ const tunnelDownNoAgents = (!tunnelReady && !agentsAvailable);
1525
+ if (watcherDownAndFailed || tunnelDownNoAgents) {
1526
+ level = 'blocked';
1527
+ } else if (failedCount > 2 || staleCount > 3 || !watcherHealthy) {
1528
+ level = 'degraded';
1529
+ } else if (staleCount > 0 || failedCount > 0 || !directProfileEnabled || !releaseReady) {
1530
+ level = 'warning';
1531
+ }
1532
+
1533
+ return { level: level, dimensions: dimensions };
1534
+ }
1535
+
1536
+ function healthScoreColor(level) {
1537
+ if (level === 'healthy') return { color: 'var(--pw-state-success)', bg: 'var(--pw-state-success-bg)' };
1538
+ if (level === 'warning') return { color: 'var(--pw-state-warning)', bg: 'var(--pw-state-warning-bg)' };
1539
+ if (level === 'degraded') return { color: '#f97316', bg: 'rgba(249, 115, 22, 0.1)' };
1540
+ if (level === 'blocked') return { color: 'var(--pw-state-error)', bg: 'var(--pw-state-error-bg)' };
1541
+ return { color: 'var(--pw-text-secondary)', bg: 'var(--pw-bg-hover)' };
1542
+ }
1543
+
1544
+ function renderHealthScore(score) {
1545
+ const card = $('health-score-card');
1546
+ const badge = $('health-score-badge');
1547
+ const body = $('health-score-body');
1548
+ if (!card || !badge || !body || !score) return;
1549
+
1550
+ const c = healthScoreColor(score.level);
1551
+ badge.textContent = score.level;
1552
+ badge.style.color = c.color;
1553
+ badge.style.backgroundColor = c.bg;
1554
+
1555
+ let html = '';
1556
+ score.dimensions.forEach(function (d) {
1557
+ const okColor = d.ok ? 'var(--pw-state-success)' : 'var(--pw-state-error)';
1558
+ const icon = d.ok ? '✓' : '✗';
1559
+ html +=
1560
+ '<div class="flex items-center gap-2 px-2 py-1.5 rounded" style="background-color: var(--pw-bg-hover); border: 1px solid var(--pw-border-subtle);">'
1561
+ + '<span class="shrink-0 text-xs font-semibold" style="color: ' + okColor + '; font-family: var(--pw-font-mono);">' + icon + '</span>'
1562
+ + '<div class="min-w-0 flex-1">'
1563
+ + '<div class="text-xs truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans);">' + escapeHtml(d.label) + '</div>'
1564
+ + '<div class="text-xs truncate" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);" title="' + escapeHtml(d.detail) + '">' + escapeHtml(d.detail) + '</div>'
1565
+ + '</div>'
1566
+ + '</div>';
1567
+ });
1568
+ body.innerHTML = html;
1569
+ }
1570
+
1571
+ async function refreshHealthScore() {
1572
+ const card = $('health-score-card');
1573
+ if (!card) return;
1574
+ try {
1575
+ // Fetch all required data in parallel
1576
+ const [statusRes, staleRes, policyRes, releaseRes, diagRes] = await Promise.allSettled([
1577
+ fetchJson('/api/status'),
1578
+ fetchJson('/api/tasks/stale'),
1579
+ fetchJson('/api/project-policy?repo_path=' + encodeURIComponent(currentRepoPath)),
1580
+ fetchJson('/api/release/status?repo_path=' + encodeURIComponent(currentRepoPath)),
1581
+ fetchJson('/api/diagnostics')
1582
+ ]);
1583
+
1584
+ const status = statusRes.status === 'fulfilled' ? statusRes.value : null;
1585
+ const staleData = staleRes.status === 'fulfilled' ? staleRes.value : null;
1586
+ const policyData = policyRes.status === 'fulfilled' ? policyRes.value : null;
1587
+ const releaseData = releaseRes.status === 'fulfilled' ? releaseRes.value : null;
1588
+ const diagData = diagRes.status === 'fulfilled' ? diagRes.value : null;
1589
+
1590
+ // stale task count
1591
+ let staleTaskCount = 0;
1592
+ if (staleData) {
1593
+ const arr = Array.isArray(staleData.stale_tasks) ? staleData.stale_tasks
1594
+ : (Array.isArray(staleData.tasks) ? staleData.tasks : []);
1595
+ staleTaskCount = arr.length;
1596
+ }
1597
+
1598
+ // failed task count - derive from status.tasks
1599
+ let failedTaskCount = 0;
1600
+ if (status && status.tasks && Array.isArray(status.tasks.tasks)) {
1601
+ failedTaskCount = status.tasks.tasks.filter(function (t) {
1602
+ const s = String(t && t.status || '').toLowerCase();
1603
+ return s.indexOf('fail') >= 0;
1604
+ }).length;
1605
+ }
1606
+
1607
+ // policy valid
1608
+ const policyValid = !!(policyData && policyData.valid !== false);
1609
+
1610
+ // release ready
1611
+ const readyState = releaseData ? (releaseData.ready_state || 'unknown') : 'unknown';
1612
+ const releaseReady = readyState === 'ready';
1613
+
1614
+ // direct profile enabled - from diagnostics or status
1615
+ let directProfileEnabled = false;
1616
+ if (diagData && typeof diagData.direct_profile_enabled === 'boolean') {
1617
+ directProfileEnabled = diagData.direct_profile_enabled;
1618
+ } else if (status && status.config && typeof status.config.enableDirectProfile === 'boolean') {
1619
+ directProfileEnabled = status.config.enableDirectProfile;
1620
+ }
1621
+
1622
+ const score = calculateHealthScore(status, {
1623
+ staleTaskCount: staleTaskCount,
1624
+ failedTaskCount: failedTaskCount,
1625
+ policyValid: policyValid,
1626
+ releaseReady: releaseReady,
1627
+ releaseReadyState: readyState,
1628
+ directProfileEnabled: directProfileEnabled
1629
+ });
1630
+ renderHealthScore(score);
1631
+ } catch (err) {
1632
+ const body = $('health-score-body');
1633
+ const badge = $('health-score-badge');
1634
+ if (body) body.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">健康评分加载失败: ' + escapeHtml(err.message) + '</div>';
1635
+ if (badge) { badge.textContent = 'error'; badge.style.color = 'var(--pw-state-error)'; badge.style.backgroundColor = 'var(--pw-state-error-bg)'; }
1636
+ }
1637
+ }
1638
+
1097
1639
  async function refreshProjectPolicy() {
1098
1640
  const body = $('policy-overview-body');
1099
1641
  const status = $('policy-status');
1100
1642
  if (!body) return;
1101
1643
  try {
1102
- const data = await fetchJson('/api/project-policy?repo_path=.');
1644
+ const data = await fetchJson('/api/project-policy?repo_path=' + encodeURIComponent(currentRepoPath));
1103
1645
  const valid = data && data.valid !== false;
1104
- if (status) status.textContent = valid ? 'valid' : 'issues';
1646
+ if (status) {
1647
+ status.textContent = valid ? 'valid' : 'issues';
1648
+ status.style.color = valid ? 'var(--pw-state-success)' : 'var(--pw-state-error)';
1649
+ }
1105
1650
  const issueCount = Array.isArray(data.issues) ? data.issues.length : 0;
1106
1651
  const policy = data.effective_policy || {};
1107
1652
  const release = (policy.release_mode || {});
1653
+
1654
+ // auto_cleanup: boolean or object with enabled field
1655
+ const autoCleanupRaw = policy.auto_cleanup;
1656
+ let autoCleanupEnabled = false;
1657
+ if (autoCleanupRaw === true) autoCleanupEnabled = true;
1658
+ else if (autoCleanupRaw && autoCleanupRaw.enabled === true) autoCleanupEnabled = true;
1659
+
1660
+ // protected_paths / high_risk_commands counts
1661
+ const protectedPathsCount = Array.isArray(policy.protected_paths) ? policy.protected_paths.length : 0;
1662
+ const highRiskCmdCount = Array.isArray(policy.high_risk_commands) ? policy.high_risk_commands.length : 0;
1663
+
1664
+ // release_mode summary
1665
+ const versionSource = release.version_source || 'package.json';
1666
+ const readyState = release.ready_state || '';
1667
+
1668
+ function row(label, valueHtml) {
1669
+ return '<div class="flex items-center justify-between gap-2 py-1" style="border-bottom: 1px solid var(--pw-border-subtle);">'
1670
+ + '<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-secondary);">' + label + '</span>'
1671
+ + '<span class="text-xs text-right min-w-0 truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + valueHtml + '</span>'
1672
+ + '</div>';
1673
+ }
1674
+
1108
1675
  body.innerHTML =
1109
- '<div class="flex items-center justify-between gap-2 mb-2">'
1676
+ '<div class="flex items-center justify-between gap-2 mb-1">'
1110
1677
  + '<span class="text-xs" style="color: var(--pw-text-secondary);">policy file</span>'
1111
1678
  + badgeHtml(data.exists ? 'present' : 'defaults', data.exists ? 'success' : 'warning')
1112
1679
  + '</div>'
1113
- + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">issues: ' + issueCount + '</div>'
1114
- + '<div class="text-xs truncate" style="color: var(--pw-text-tertiary);" title="' + escapeHtml(release.version_source || '') + '">version: ' + escapeHtml(release.version_source || 'package.json') + '</div>';
1680
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1681
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">validity</span>'
1682
+ + badgeHtml(valid ? 'valid' : 'invalid', valid ? 'success' : 'error')
1683
+ + '</div>'
1684
+ + row('issues', String(issueCount))
1685
+ + row('auto_cleanup', autoCleanupEnabled
1686
+ ? '<span style="color: var(--pw-state-success);">enabled</span>'
1687
+ : '<span style="color: var(--pw-text-tertiary);">disabled</span>')
1688
+ + row('protected_paths', String(protectedPathsCount))
1689
+ + row('high_risk_commands', String(highRiskCmdCount))
1690
+ + row('version_source', escapeHtml(versionSource))
1691
+ + (readyState ? row('ready_state', escapeHtml(readyState)) : '');
1115
1692
  } catch (err) {
1116
1693
  if (status) status.textContent = 'error';
1117
1694
  body.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
@@ -1123,19 +1700,89 @@ html.dark {
1123
1700
  const status = $('release-status');
1124
1701
  if (!body) return;
1125
1702
  try {
1126
- const data = await fetchJson('/api/release/status?repo_path=.');
1127
- const ready = data && data.policy_valid && data.release_readiness && data.release_readiness.version_consistent !== false;
1128
- if (status) status.textContent = ready ? 'ready' : 'blocked';
1129
- const readiness = data.release_readiness || {};
1130
- const commands = Array.isArray(readiness.required_commands) ? readiness.required_commands : [];
1131
- const blocked = commands.filter(function (entry) { return !entry.allowed; }).length;
1703
+ const data = await fetchJson('/api/release/status?repo_path=' + encodeURIComponent(currentRepoPath));
1704
+ const vs = data.version_source || '';
1705
+ const ver = data.version || '';
1706
+ const consistent = data.version_consistent;
1707
+ const commands = Array.isArray(data.required_commands) ? data.required_commands : [];
1708
+ const blockedCount = data.commands_blocked_count != null ? data.commands_blocked_count : commands.filter(function (e) { return !e.allowed; }).length;
1709
+ const readyState = data.ready_state || 'unknown';
1710
+ const nextAction = data.next_action || '';
1711
+
1712
+ // Version display logic
1713
+ let versionDisplay;
1714
+ if (vs === 'workspace_root' || !ver) {
1715
+ versionDisplay = '选择具体 repo 以读取 version';
1716
+ } else {
1717
+ versionDisplay = ver;
1718
+ }
1719
+
1720
+ // ready_state badge
1721
+ let readyKind = 'warning';
1722
+ if (readyState === 'ready') readyKind = 'success';
1723
+ else if (readyState === 'blocked') readyKind = 'error';
1724
+
1725
+ // version_consistent badge
1726
+ const consistentKind = consistent === true ? 'success' : (consistent === false ? 'error' : 'neutral');
1727
+ const consistentText = consistent === true ? '✓' : (consistent === false ? '✗' : '—');
1728
+
1729
+ // commands list
1730
+ let cmdHtml = '';
1731
+ if (commands.length > 0) {
1732
+ cmdHtml = '<div class="mt-2 space-y-1">';
1733
+ commands.forEach(function (cmd) {
1734
+ const allowedIcon = cmd.allowed ? '✓' : '✗';
1735
+ const cmdKind = cmd.allowed ? 'success' : 'error';
1736
+ const c = statusColors(cmdKind);
1737
+ cmdHtml += '<div class="flex items-center gap-2 text-xs">'
1738
+ + '<span style="color: ' + c.color + ';">' + allowedIcon + '</span>'
1739
+ + '<span style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(cmd.command) + '</span>'
1740
+ + (cmd.blocked_reason ? '<span style="color: var(--pw-state-warning); font-family: var(--pw-font-sans);">(' + escapeHtml(cmd.blocked_reason) + ')</span>' : '')
1741
+ + '</div>';
1742
+ });
1743
+ cmdHtml += '</div>';
1744
+ }
1745
+
1746
+ // blocked reason text
1747
+ let blockedReasonHtml = '';
1748
+ if (readyState === 'blocked' && data.blocked_reason) {
1749
+ blockedReasonHtml = '<div class="text-xs mt-2" style="color: var(--pw-state-error);">' + escapeHtml(data.blocked_reason) + '</div>';
1750
+ }
1751
+
1752
+ if (status) {
1753
+ status.textContent = readyState;
1754
+ const sc = statusColors(readyKind);
1755
+ status.style.color = sc.color;
1756
+ }
1757
+
1132
1758
  body.innerHTML =
1133
- '<div class="flex items-center justify-between gap-2 mb-2">'
1759
+ '<div class="flex items-center justify-between gap-2 mb-1">'
1760
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">package</span>'
1761
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(data.package_name || '—') + '</span>'
1762
+ + '</div>'
1763
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1764
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">version_source</span>'
1765
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(vs || 'unknown') + '</span>'
1766
+ + '</div>'
1767
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1134
1768
  + '<span class="text-xs" style="color: var(--pw-text-secondary);">version</span>'
1135
- + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(readiness.version || 'unknown') + '</span>'
1769
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(versionDisplay) + '</span>'
1770
+ + '</div>'
1771
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1772
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">consistent</span>'
1773
+ + '<span class="text-xs" style="color: ' + statusColors(consistentKind).color + ';">' + consistentText + '</span>'
1136
1774
  + '</div>'
1137
- + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">commands blocked: ' + blocked + ' / ' + commands.length + '</div>'
1138
- + '<div class="text-xs" style="color: var(--pw-text-tertiary);">' + escapeHtml(data.next_action || '') + '</div>';
1775
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1776
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">commands</span>'
1777
+ + '<span class="text-xs" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + blockedCount + ' / ' + commands.length + ' blocked</span>'
1778
+ + '</div>'
1779
+ + '<div class="flex items-center justify-between gap-2 mb-1">'
1780
+ + '<span class="text-xs" style="color: var(--pw-text-secondary);">ready</span>'
1781
+ + badgeHtml(readyState, readyKind)
1782
+ + '</div>'
1783
+ + cmdHtml
1784
+ + blockedReasonHtml
1785
+ + (nextAction ? '<div class="text-xs mt-1" style="color: var(--pw-text-tertiary);">next: ' + escapeHtml(nextAction) + '</div>' : '');
1139
1786
  } catch (err) {
1140
1787
  if (status) status.textContent = 'error';
1141
1788
  body.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
@@ -1150,24 +1797,261 @@ html.dark {
1150
1797
  const data = await fetchJson('/api/evidence-packs');
1151
1798
  const packs = Array.isArray(data.evidence_packs) ? data.evidence_packs : [];
1152
1799
  if (status) status.textContent = String(data.total || packs.length);
1800
+ const hasLineages = data.has_lineages === true;
1801
+ const pendingIds = Array.isArray(data.pending_lineage_ids) ? data.pending_lineage_ids : [];
1802
+
1153
1803
  if (!packs.length) {
1154
- body.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">No exported evidence pack yet</div>';
1804
+ if (!hasLineages) {
1805
+ body.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">Evidence pack is available after run_task_loop.</div>';
1806
+ return;
1807
+ }
1808
+ // Lineage exists but no evidence pack yet — offer export for the most recent pending lineage.
1809
+ const exportId = pendingIds.length > 0 ? pendingIds[0] : null;
1810
+ if (exportId) {
1811
+ body.innerHTML =
1812
+ '<div class="text-xs mb-2" style="color: var(--pw-text-tertiary);">Lineage exists but no evidence pack yet.</div>'
1813
+ + '<div class="flex items-center gap-2">'
1814
+ + '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-export-pack="' + escapeHtml(exportId) + '" type="button" style="background-color: var(--pw-accent-muted); color: var(--pw-accent); border: 1px solid var(--pw-accent);">Export evidence pack</button>'
1815
+ + '<button class="pw-quick-btn" data-copy-id="' + escapeHtml(exportId) + '" title="复制 lineage_id" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
1816
+ + '</div>';
1817
+ const exportBtn = body.querySelector('[data-export-pack]');
1818
+ if (exportBtn) {
1819
+ exportBtn.addEventListener('click', function () {
1820
+ const lid = exportBtn.getAttribute('data-export-pack');
1821
+ if (!lid) return;
1822
+ postAction('/api/evidence-packs/' + encodeURIComponent(lid) + '/export', exportBtn).then(function () {
1823
+ showToast('success', '导出成功', 'Evidence pack 已导出');
1824
+ refreshEvidencePacks();
1825
+ }).catch(function (err) {
1826
+ showToast('error', '导出失败', err.message);
1827
+ });
1828
+ });
1829
+ }
1830
+ const copyBtn = body.querySelector('[data-copy-id]');
1831
+ if (copyBtn) {
1832
+ copyBtn.addEventListener('click', function (e) {
1833
+ e.preventDefault();
1834
+ e.stopPropagation();
1835
+ copyToClipboard(copyBtn.getAttribute('data-copy-id'), 'lineage_id');
1836
+ });
1837
+ }
1838
+ } else {
1839
+ body.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">No exported evidence pack yet</div>';
1840
+ }
1155
1841
  return;
1156
1842
  }
1843
+
1157
1844
  const top = packs[0];
1845
+ const exportStatus = top.export_status || 'exported';
1846
+ const jsonExists = top.evidence_json_exists === true;
1847
+ const mdExists = top.evidence_md_exists === true;
1848
+ const exportedAt = top.exported_at || (top.generated_at || '—');
1849
+ const lineageId = top.lineage_id || '';
1158
1850
  body.innerHTML =
1159
1851
  '<div class="flex items-center justify-between gap-2 mb-2">'
1160
- + '<span class="text-xs truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(top.lineage_id || '') + '">' + escapeHtml(shortId(top.lineage_id)) + '</span>'
1161
- + badgeHtml(top.lineage && top.lineage.stop_reason ? top.lineage.stop_reason : 'unknown', statusKind(top.lineage && top.lineage.final_status))
1852
+ + '<span class="text-xs truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(lineageId) + '">' + escapeHtml(shortId(lineageId)) + '</span>'
1853
+ + badgeHtml(exportStatus, exportStatus === 'exported' ? 'success' : 'warning')
1162
1854
  + '</div>'
1163
- + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">policy issues: ' + escapeHtml(String(top.policy ? top.policy.issue_count : 0)) + '</div>'
1164
- + '<div class="text-xs truncate" style="color: var(--pw-text-tertiary);" title="' + escapeHtml(top.next_action || '') + '">Next: ' + escapeHtml(top.next_action || 'review') + '</div>';
1855
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">evidence.json: ' + (jsonExists ? '✓' : '✗') + ' · EVIDENCE.md: ' + (mdExists ? '✓' : '✗') + '</div>'
1856
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);" title="' + escapeHtml(exportedAt) + '">exported_at: ' + escapeHtml(fmtTime(exportedAt)) + '</div>'
1857
+ + '<div class="text-xs mb-2" style="color: var(--pw-text-tertiary);">policy issues: ' + escapeHtml(String(top.policy ? top.policy.issue_count : 0)) + '</div>'
1858
+ + '<div class="flex items-center gap-2 flex-wrap">'
1859
+ + (exportStatus !== 'exported' ? '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-export-pack="' + escapeHtml(lineageId) + '" type="button" style="background-color: var(--pw-accent-muted); color: var(--pw-accent); border: 1px solid var(--pw-accent);">Export evidence pack</button>' : '')
1860
+ + '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-view-evidence="' + escapeHtml(lineageId) + '" type="button" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">View detail</button>'
1861
+ + '<button class="pw-quick-btn" data-copy-id="' + escapeHtml(lineageId) + '" title="复制 lineage_id" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
1862
+ + '</div>';
1863
+ // Wire export button (if present)
1864
+ const exportBtn2 = body.querySelector('[data-export-pack]');
1865
+ if (exportBtn2) {
1866
+ exportBtn2.addEventListener('click', function () {
1867
+ const lid = exportBtn2.getAttribute('data-export-pack');
1868
+ if (!lid) return;
1869
+ postAction('/api/evidence-packs/' + encodeURIComponent(lid) + '/export', exportBtn2).then(function () {
1870
+ showToast('success', '导出成功', 'Evidence pack 已导出');
1871
+ refreshEvidencePacks();
1872
+ }).catch(function (err) {
1873
+ showToast('error', '导出失败', err.message);
1874
+ });
1875
+ });
1876
+ }
1877
+ // Wire view detail button
1878
+ const viewBtn = body.querySelector('[data-view-evidence]');
1879
+ if (viewBtn) {
1880
+ viewBtn.addEventListener('click', function () {
1881
+ const lid = viewBtn.getAttribute('data-view-evidence');
1882
+ if (lid) showEvidencePackDetail(lid);
1883
+ });
1884
+ }
1885
+ // Wire copy button
1886
+ const copyBtn2 = body.querySelector('[data-copy-id]');
1887
+ if (copyBtn2) {
1888
+ copyBtn2.addEventListener('click', function (e) {
1889
+ e.preventDefault();
1890
+ e.stopPropagation();
1891
+ copyToClipboard(copyBtn2.getAttribute('data-copy-id'), 'lineage_id');
1892
+ });
1893
+ }
1165
1894
  } catch (err) {
1166
1895
  if (status) status.textContent = 'error';
1167
1896
  body.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
1168
1897
  }
1169
1898
  }
1170
1899
 
1900
+ async function showEvidencePackDetail(lineageId) {
1901
+ if (!lineageId) return;
1902
+ const modal = $('safe-modal');
1903
+ const titleEl = $('safe-modal-title');
1904
+ const bodyEl = $('safe-modal-body');
1905
+ if (!modal || !titleEl || !bodyEl) return;
1906
+ titleEl.textContent = 'Evidence Pack · ' + shortId(lineageId);
1907
+ bodyEl.innerHTML = '<div class="text-xs" style="color: var(--pw-text-tertiary);">Loading…</div>';
1908
+ modal.classList.remove('hidden');
1909
+ try {
1910
+ const data = await fetchJson('/api/evidence-packs/' + encodeURIComponent(lineageId));
1911
+ const jsonPath = data.files && data.files.json ? data.files.json : '';
1912
+ const mdPath = data.files && data.files.markdown ? data.files.markdown : '';
1913
+ const lineage = data.lineage || {};
1914
+ bodyEl.innerHTML =
1915
+ '<div class="mb-3">'
1916
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-secondary);"><span style="color: var(--pw-text-tertiary);">lineage_id:</span> ' + escapeHtml(data.lineage_id || lineageId) + '</div>'
1917
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-secondary);"><span style="color: var(--pw-text-tertiary);">generated_at:</span> ' + escapeHtml(fmtTime(data.generated_at || '—')) + '</div>'
1918
+ + '<div class="flex items-center gap-2 mb-1 flex-wrap">'
1919
+ + '<span class="text-xs" style="color: var(--pw-text-tertiary);">final_status:</span>' + badgeHtml(lineage.final_status || 'unknown', statusKind(lineage.final_status))
1920
+ + '<span class="text-xs" style="color: var(--pw-text-tertiary);">stop_reason:</span><span class="text-xs" style="color: var(--pw-text-secondary);">' + escapeHtml(lineage.stop_reason || '—') + '</span>'
1921
+ + '</div>'
1922
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">policy valid: ' + (data.policy && data.policy.valid ? '✓' : '✗') + ' · issues: ' + escapeHtml(String(data.policy ? data.policy.issue_count : 0)) + '</div>'
1923
+ + (data.next_action ? '<div class="text-xs mb-1" style="color: var(--pw-text-tertiary);">next: ' + escapeHtml(data.next_action) + '</div>' : '')
1924
+ + '</div>'
1925
+ + '<div class="mt-2">'
1926
+ + '<div class="text-xs font-semibold mb-1" style="color: var(--pw-text-primary);">Files</div>'
1927
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-secondary); font-family: var(--pw-font-mono); word-break: break-all;" title="' + escapeHtml(jsonPath) + '">evidence.json: ' + (jsonPath ? escapeHtml(jsonPath) : '—') + '</div>'
1928
+ + '<div class="text-xs mb-1" style="color: var(--pw-text-secondary); font-family: var(--pw-font-mono); word-break: break-all;" title="' + escapeHtml(mdPath) + '">EVIDENCE.md: ' + (mdPath ? escapeHtml(mdPath) : '—') + '</div>'
1929
+ + '<div class="flex items-center gap-2 mt-2">'
1930
+ + (jsonPath ? '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-copy-path="' + escapeHtml(jsonPath) + '" type="button" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">Copy evidence.json path</button>' : '')
1931
+ + (mdPath ? '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-copy-path="' + escapeHtml(mdPath) + '" type="button" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">Copy EVIDENCE.md path</button>' : '')
1932
+ + '</div>'
1933
+ + '</div>';
1934
+ // Wire copy path buttons
1935
+ bodyEl.querySelectorAll('[data-copy-path]').forEach(function (btn) {
1936
+ btn.addEventListener('click', function () {
1937
+ copyToClipboard(btn.getAttribute('data-copy-path'), 'file path');
1938
+ });
1939
+ });
1940
+ } catch (err) {
1941
+ bodyEl.innerHTML = '<div class="text-xs" style="color: var(--pw-state-error);">' + escapeHtml(err.message) + '</div>';
1942
+ }
1943
+ }
1944
+
1945
+ // ---------- Repo selector ----------
1946
+ async function loadRepos() {
1947
+ const select = $('repo-selector');
1948
+ if (!select) return;
1949
+ try {
1950
+ const data = await fetchJson('/api/workspace/repos');
1951
+ const repos = Array.isArray(data.repos) ? data.repos : [];
1952
+ let html = '<option value=".">workspace root</option>';
1953
+ repos.forEach(function (r) {
1954
+ const label = r.name + (r.has_package_json && r.version ? ' (v' + r.version + ')' : '');
1955
+ const val = r.path || r.name;
1956
+ html += '<option value="' + escapeHtml(val) + '">' + escapeHtml(label) + '</option>';
1957
+ });
1958
+ select.innerHTML = html;
1959
+ } catch (err) {
1960
+ // keep default option
1961
+ console.error('loadRepos failed:', err);
1962
+ }
1963
+ }
1964
+
1965
+ function onRepoChange(selectedRepoPath) {
1966
+ currentRepoPath = selectedRepoPath || '.';
1967
+ Promise.all([
1968
+ refreshProjectPolicy(),
1969
+ refreshReleaseStatus(),
1970
+ refreshTasks(),
1971
+ refreshLineages(),
1972
+ refreshEvidencePacks(),
1973
+ refreshStaleTasks(),
1974
+ refreshHealthScore()
1975
+ ]).catch(function (err) {
1976
+ showError('切换 repo 刷新失败: ' + err.message);
1977
+ });
1978
+ }
1979
+
1980
+ // ---------- Stale tasks ----------
1981
+ async function refreshStaleTasks() {
1982
+ const body = $('stale-tasks-body');
1983
+ const countEl = $('stale-tasks-count');
1984
+ const card = $('stale-tasks-card');
1985
+ if (!body) return;
1986
+ try {
1987
+ const data = await fetchJson('/api/tasks/stale');
1988
+ const staleTasks = Array.isArray(data.stale_tasks) ? data.stale_tasks : [];
1989
+ const total = (typeof data.total === 'number') ? data.total : staleTasks.length;
1990
+ if (countEl) countEl.textContent = String(total);
1991
+ if (!staleTasks.length) {
1992
+ if (card) card.classList.add('hidden');
1993
+ return;
1994
+ }
1995
+ if (card) card.classList.remove('hidden');
1996
+ let html = '';
1997
+ staleTasks.forEach(function (st) {
1998
+ const taskId = st.task_id || '';
1999
+ const href = '/pages/task-detail.html?id=' + encodeURIComponent(taskId);
2000
+ const statusBadge = badgeHtml(st.status || '—', statusKind(st.status));
2001
+ const errorText = st.error ? escapeHtml(st.error.length > 100 ? st.error.slice(0, 100) + '…' : st.error) : '';
2002
+ const explanation = st.explanation || '';
2003
+ const nextAction = st.next_action || '';
2004
+ html +=
2005
+ '<div class="py-3" style="border-bottom: 1px solid var(--pw-border-subtle);" data-stale-task="' + escapeHtml(taskId) + '">'
2006
+ + '<div class="flex items-center gap-2 mb-1.5 flex-wrap">'
2007
+ + '<span class="text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap" style="color: var(--pw-text-secondary); background-color: var(--pw-bg-hover); font-family: var(--pw-font-mono);" title="' + escapeHtml(taskId) + '">' + escapeHtml(shortId(taskId)) + '</span>'
2008
+ + statusBadge
2009
+ + '<span class="text-xs truncate" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono); max-width: 200px;" title="' + escapeHtml(st.repo_path || '') + '">' + escapeHtml(st.repo_path || '—') + '</span>'
2010
+ + '<div class="flex-1"></div>'
2011
+ + '<button class="pw-quick-btn" data-copy-id="' + escapeHtml(taskId) + '" title="复制 task_id" type="button"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
2012
+ + '</div>'
2013
+ + (explanation ? '<div class="pw-stale-explanation mb-1.5">' + escapeHtml(explanation) + '</div>' : '')
2014
+ + (errorText ? '<div class="text-xs mb-1" style="color: var(--pw-state-error); font-family: var(--pw-font-mono);" title="' + escapeHtml(st.error || '') + '">' + errorText + '</div>' : '')
2015
+ + (nextAction ? '<div class="text-xs mb-1.5" style="color: var(--pw-text-secondary);">next: ' + escapeHtml(nextAction) + '</div>' : '')
2016
+ + '<div class="flex items-center gap-2">'
2017
+ + '<a href="' + href + '" class="text-xs px-2.5 py-1 rounded whitespace-nowrap" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); border: 1px solid var(--pw-border-subtle);">查看详情</a>'
2018
+ + '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" data-hide-stale="' + escapeHtml(taskId) + '" type="button" style="background-color: var(--pw-state-error-bg); color: var(--pw-state-error); border: 1px solid var(--pw-state-error);">隐藏</button>'
2019
+ + '<button class="text-xs px-2.5 py-1 rounded whitespace-nowrap" disabled style="background-color: var(--pw-bg-hover); color: var(--pw-text-tertiary); border: 1px solid var(--pw-border-subtle);">重建任务</button>'
2020
+ + '</div>'
2021
+ + '</div>';
2022
+ });
2023
+ body.innerHTML = html;
2024
+ // Wire copy buttons
2025
+ body.querySelectorAll('[data-copy-id]').forEach(function (btn) {
2026
+ btn.addEventListener('click', function (e) {
2027
+ e.preventDefault();
2028
+ e.stopPropagation();
2029
+ copyToClipboard(btn.getAttribute('data-copy-id'), 'task_id');
2030
+ });
2031
+ });
2032
+ // Wire hide-stale buttons
2033
+ body.querySelectorAll('[data-hide-stale]').forEach(function (btn) {
2034
+ btn.addEventListener('click', function (e) {
2035
+ e.preventDefault();
2036
+ e.stopPropagation();
2037
+ const tid = btn.getAttribute('data-hide-stale');
2038
+ postAction('/api/tasks/' + encodeURIComponent(tid) + '/hide-stale', btn).then(function () {
2039
+ showToast('success', '已隐藏', 'Stale 任务已隐藏');
2040
+ const row = btn.closest('[data-stale-task]');
2041
+ if (row && row.parentNode) row.parentNode.removeChild(row);
2042
+ const remaining = body.querySelectorAll('[data-stale-task]').length;
2043
+ if (countEl) countEl.textContent = String(remaining);
2044
+ if (remaining === 0 && card) card.classList.add('hidden');
2045
+ }).catch(function (err) {
2046
+ showToast('error', '隐藏失败', err.message);
2047
+ });
2048
+ });
2049
+ });
2050
+ } catch (err) {
2051
+ if (card) card.classList.add('hidden');
2052
+ }
2053
+ }
2054
+
1171
2055
  // ---------- Refresh orchestrators ----------
1172
2056
  async function refreshStatus() {
1173
2057
  try {
@@ -1197,7 +2081,7 @@ html.dark {
1197
2081
 
1198
2082
  async function refreshTasks() {
1199
2083
  try {
1200
- const data = await fetchJson('/api/tasks');
2084
+ const data = await fetchJson('/api/tasks?repo_path=' + encodeURIComponent(currentRepoPath));
1201
2085
  renderTasks(data);
1202
2086
  } catch (err) {
1203
2087
  const bodyEl = $('recent-tasks-body');
@@ -1208,14 +2092,14 @@ html.dark {
1208
2092
  }
1209
2093
 
1210
2094
  async function refreshAll() {
1211
- await Promise.all([refreshStatus(), refreshTasks(), refreshLogs(), refreshEvents(), refreshLineages(), refreshProjectPolicy(), refreshReleaseStatus(), refreshEvidencePacks()]);
2095
+ await Promise.all([refreshStatus(), refreshTasks(), refreshLogs(), refreshEvents(), refreshLineages(), refreshProjectPolicy(), refreshReleaseStatus(), refreshEvidencePacks(), refreshStaleTasks(), refreshHealthScore()]);
1212
2096
  }
1213
2097
 
1214
2098
  // ---------- Actions ----------
1215
2099
  async function handleAction(action, btnEl) {
1216
2100
  try {
1217
2101
  if (action === 'refresh') {
1218
- await Promise.all([refreshStatus(), refreshTasks(), refreshLineages(), refreshProjectPolicy(), refreshReleaseStatus(), refreshEvidencePacks()]);
2102
+ await Promise.all([refreshStatus(), refreshTasks(), refreshLineages(), refreshProjectPolicy(), refreshReleaseStatus(), refreshEvidencePacks(), refreshStaleTasks(), refreshHealthScore()]);
1219
2103
  return;
1220
2104
  }
1221
2105
 
@@ -1269,6 +2153,35 @@ html.dark {
1269
2153
  });
1270
2154
  });
1271
2155
 
2156
+ // Copy diagnostics button (system status card header)
2157
+ const copyDiagBtn = $('btn-copy-diagnostics');
2158
+ if (copyDiagBtn) {
2159
+ copyDiagBtn.addEventListener('click', function () {
2160
+ copyDiagnostics(copyDiagBtn);
2161
+ });
2162
+ }
2163
+
2164
+ // Repo selector change handler
2165
+ const repoSelector = $('repo-selector');
2166
+ if (repoSelector) {
2167
+ repoSelector.addEventListener('change', function () {
2168
+ onRepoChange(repoSelector.value);
2169
+ });
2170
+ }
2171
+
2172
+ // Safe modal close handlers
2173
+ const modalClose = $('safe-modal-close');
2174
+ if (modalClose) {
2175
+ modalClose.addEventListener('click', function () { closeSafeModal(); });
2176
+ }
2177
+ const safeModal = $('safe-modal');
2178
+ if (safeModal) {
2179
+ safeModal.addEventListener('click', function (e) {
2180
+ if (e.target === safeModal) closeSafeModal();
2181
+ });
2182
+ }
2183
+
2184
+ loadRepos();
1272
2185
  loadToken().then(function () {
1273
2186
  auditSeeded = false;
1274
2187
  seenAuditKeys = new Set();
@@ -1282,6 +2195,8 @@ html.dark {
1282
2195
 
1283
2196
  // Auto-refresh /api/status every 30s (no logs to avoid performance issues)
1284
2197
  statusTimer = setInterval(function () { refreshStatus(); }, 30000);
2198
+ // Health score refresh (lightweight, reuses some endpoints) every 60s
2199
+ setInterval(function () { refreshHealthScore(); }, 60000);
1285
2200
  // Refresh activity timeline every 60s
1286
2201
  setInterval(function () { refreshEvents(); }, 60000);
1287
2202
  // Audit toast polling: every 90s, best-effort