coding-agent-harness 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/CONTRIBUTING.md +8 -4
  3. package/README.md +12 -2
  4. package/README.zh-CN.md +10 -2
  5. package/SKILL.md +14 -3
  6. package/dist/build-dist.mjs +19 -6
  7. package/dist/check-dist-observation.mjs +57 -29
  8. package/dist/check-harness.mjs +0 -1
  9. package/dist/check-import-graph.mjs +44 -27
  10. package/dist/check-lite-forbidden-surfaces.mjs +121 -0
  11. package/dist/check-no-ts-nocheck.mjs +7 -7
  12. package/dist/check-runtime-emit.mjs +10 -3
  13. package/dist/check-type-boundaries.mjs +51 -9
  14. package/dist/commands/dashboard-command.mjs +52 -14
  15. package/dist/commands/migration-command.mjs +18 -8
  16. package/dist/commands/module-command.mjs +142 -0
  17. package/dist/commands/preset-command.mjs +51 -12
  18. package/dist/commands/registry.mjs +483 -0
  19. package/dist/commands/task-command.mjs +109 -52
  20. package/dist/harness.mjs +6 -304
  21. package/dist/lib/capability-registry.mjs +229 -53
  22. package/dist/lib/check-module-parallel.mjs +1 -6
  23. package/dist/lib/check-profiles.mjs +39 -46
  24. package/dist/lib/check-task-contracts.mjs +6 -4
  25. package/dist/lib/command-registry.mjs +248 -0
  26. package/dist/lib/core-shared.mjs +78 -3
  27. package/dist/lib/dashboard-data.mjs +203 -22
  28. package/dist/lib/dashboard-workbench.mjs +245 -21
  29. package/dist/lib/dashboard-writer.mjs +4 -1
  30. package/dist/lib/git-status-summary.mjs +0 -1
  31. package/dist/lib/governance-index-generator.mjs +7 -5
  32. package/dist/lib/governance-sync.mjs +46 -121
  33. package/dist/lib/governance-table-boundary.mjs +1 -14
  34. package/dist/lib/harness-core.mjs +4 -1
  35. package/dist/lib/harness-paths.mjs +115 -1
  36. package/dist/lib/impact-classifier.mjs +420 -0
  37. package/dist/lib/lesson-maintenance.mjs +1 -2
  38. package/dist/lib/markdown-utils.mjs +50 -1
  39. package/dist/lib/migration-planner.mjs +31 -16
  40. package/dist/lib/migration-support.mjs +5 -4
  41. package/dist/lib/module-registry.mjs +296 -0
  42. package/dist/lib/preset-audit-contracts.mjs +24 -1
  43. package/dist/lib/preset-engine.mjs +67 -29
  44. package/dist/lib/preset-registry.mjs +361 -71
  45. package/dist/lib/preset-runner.mjs +292 -26
  46. package/dist/lib/review-confirm-git-gate.mjs +73 -19
  47. package/dist/lib/status-builder.mjs +23 -8
  48. package/dist/lib/structure-migration.mjs +6 -4
  49. package/dist/lib/subagent-authorization-audit.mjs +8 -2
  50. package/dist/lib/task-archive-eligibility.mjs +65 -0
  51. package/dist/lib/task-audit-metadata.mjs +25 -11
  52. package/dist/lib/task-audit-migration.mjs +21 -14
  53. package/dist/lib/task-discovery-contract.mjs +32 -0
  54. package/dist/lib/task-index.mjs +3 -2
  55. package/dist/lib/task-lesson-candidates.mjs +1 -2
  56. package/dist/lib/task-lesson-sedimentation.mjs +310 -9
  57. package/dist/lib/task-lifecycle/create-task-helpers.mjs +6 -3
  58. package/dist/lib/task-lifecycle/phase-sync.mjs +0 -1
  59. package/dist/lib/task-lifecycle/preset-interop.mjs +16 -0
  60. package/dist/lib/task-lifecycle/review-confirm.mjs +34 -2
  61. package/dist/lib/task-lifecycle/review-gates.mjs +12 -5
  62. package/dist/lib/task-lifecycle/review-submission.mjs +1 -2
  63. package/dist/lib/task-lifecycle/scaffold-provenance.mjs +0 -1
  64. package/dist/lib/task-lifecycle/template-files.mjs +2 -5
  65. package/dist/lib/task-lifecycle.mjs +116 -160
  66. package/dist/lib/task-metadata.mjs +10 -5
  67. package/dist/lib/task-preset-contract-drift.mjs +45 -0
  68. package/dist/lib/task-repository.mjs +192 -0
  69. package/dist/lib/task-review-model.mjs +36 -17
  70. package/dist/lib/task-scanner.mjs +74 -23
  71. package/dist/lib/task-template-materials.mjs +131 -0
  72. package/dist/lib/task-tombstone-commands.mjs +186 -29
  73. package/dist/lib/types/check-profiles.js +1 -0
  74. package/dist/lib/types/impact.js +1 -0
  75. package/dist/lib/types/preset.js +1 -0
  76. package/dist/lib/types/task-lifecycle.js +1 -0
  77. package/dist/lib/types/task-scanner.js +1 -0
  78. package/dist/postinstall.mjs +2 -2
  79. package/dist/run-built-tests.mjs +10 -3
  80. package/docs-release/README.md +1 -0
  81. package/docs-release/architecture/document-contract-kernel/README.md +150 -0
  82. package/docs-release/architecture/document-contract-kernel/products/full-skill-overlay.md +29 -0
  83. package/docs-release/architecture/document-contract-kernel/products/lite-forbidden-surfaces.txt +26 -0
  84. package/docs-release/architecture/document-contract-kernel/products/lite-skill-overlay.md +37 -0
  85. package/docs-release/architecture/overview.md +2 -2
  86. package/docs-release/architecture/overview.zh-CN.md +2 -2
  87. package/docs-release/architecture/system-explainer/01-system-overview.md +11 -7
  88. package/docs-release/architecture/system-explainer/02-module-dependency.md +4 -4
  89. package/docs-release/architecture/system-explainer/03-task-lifecycle.md +17 -12
  90. package/docs-release/architecture/system-explainer/05-data-flow.md +6 -6
  91. package/docs-release/architecture/system-explainer/06-preset-and-migration.md +2 -2
  92. package/docs-release/architecture/system-explainer/README.md +1 -1
  93. package/docs-release/architecture/system-explainer/en-US/01-system-overview.md +12 -8
  94. package/docs-release/architecture/system-explainer/en-US/02-module-dependency.md +5 -5
  95. package/docs-release/architecture/system-explainer/en-US/03-task-lifecycle.md +19 -11
  96. package/docs-release/architecture/system-explainer/en-US/05-data-flow.md +5 -5
  97. package/docs-release/architecture/system-explainer/en-US/06-preset-and-migration.md +2 -2
  98. package/docs-release/architecture/system-explainer/en-US/README.md +1 -1
  99. package/docs-release/guides/agent-installation.en-US.md +4 -6
  100. package/docs-release/guides/agent-installation.md +11 -8
  101. package/docs-release/guides/contributing.md +10 -3
  102. package/docs-release/guides/contributing.zh-CN.md +10 -3
  103. package/docs-release/guides/legacy-migration-agent-prompt.md +1 -1
  104. package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +1 -1
  105. package/docs-release/guides/migration-playbook.en-US.md +9 -6
  106. package/docs-release/guides/migration-playbook.md +9 -6
  107. package/docs-release/guides/preset-development.md +68 -2
  108. package/docs-release/guides/task-state-machine.en-US.md +8 -8
  109. package/docs-release/guides/task-state-machine.md +7 -7
  110. package/docs-release/guides/typescript-runtime-migration-closeout.md +17 -13
  111. package/package.json +16 -12
  112. package/postinstall.mjs +37 -0
  113. package/presets/legacy-migration/preset.yaml +5 -5
  114. package/presets/legacy-migration/templates/execution_strategy.append.md +1 -1
  115. package/presets/lesson-sedimentation/preset.yaml +3 -3
  116. package/presets/module/preset.yaml +2 -2
  117. package/presets/module/templates/execution_strategy.append.md +1 -1
  118. package/presets/module/templates/task_plan.append.md +3 -3
  119. package/presets/release-closeout/checks/check-release-package.mjs +6 -1
  120. package/presets/release-closeout/preset.yaml +9 -9
  121. package/presets/release-closeout/scripts/generate-release-package.mjs +387 -25
  122. package/presets/release-closeout/templates/task_plan.append.md +5 -5
  123. package/presets/standard-task/preset.yaml +2 -2
  124. package/references/agents-md-pattern.md +23 -17
  125. package/references/lessons-governance.md +2 -2
  126. package/references/module-parallel-standard.md +3 -6
  127. package/references/ssot-governance.md +2 -2
  128. package/references/taskr-gap-analysis.md +3 -3
  129. package/run-dist.mjs +34 -0
  130. package/skills/preset-creator/SKILL.md +40 -8
  131. package/skills/preset-creator/references/complex-task-skeleton/brief.md +32 -8
  132. package/skills/preset-creator/references/preset-package-skeleton.md +15 -5
  133. package/skills/preset-creator/references/structure-aware-paths.md +112 -0
  134. package/templates/AGENTS.md.template +28 -26
  135. package/templates/architecture/README.md +2 -2
  136. package/templates/architecture/service-catalog.md +2 -2
  137. package/templates/architecture/services/service-template.md +1 -1
  138. package/templates/dashboard/assets/app-src/00-state.js +5 -1
  139. package/templates/dashboard/assets/app-src/10-router.js +7 -0
  140. package/templates/dashboard/assets/app-src/20-overview.js +8 -8
  141. package/templates/dashboard/assets/app-src/30-tasks.js +132 -40
  142. package/templates/dashboard/assets/app-src/32-task-swimlane.js +314 -0
  143. package/templates/dashboard/assets/app-src/35-task-detail.js +35 -5
  144. package/templates/dashboard/assets/app-src/40-modules.js +257 -41
  145. package/templates/dashboard/assets/app-src/45-review.js +127 -1
  146. package/templates/dashboard/assets/app-src/90-bindings.js +185 -2
  147. package/templates/dashboard/assets/app.css +928 -53
  148. package/templates/dashboard/assets/app.css.manifest.json +2 -0
  149. package/templates/dashboard/assets/app.js +1071 -98
  150. package/templates/dashboard/assets/app.manifest.json +1 -0
  151. package/templates/dashboard/assets/css-src/00-foundation.css +12 -6
  152. package/templates/dashboard/assets/css-src/10-panels-flow.css +2 -2
  153. package/templates/dashboard/assets/css-src/30-task-index.css +21 -13
  154. package/templates/dashboard/assets/css-src/31-archive.css +94 -0
  155. package/templates/dashboard/assets/css-src/32-task-swimlane.css +487 -0
  156. package/templates/dashboard/assets/css-src/35-review-workspace.css +78 -0
  157. package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +191 -14
  158. package/templates/dashboard/assets/css-src/50-responsive-overrides.css +23 -0
  159. package/templates/dashboard/assets/i18n.js +166 -2
  160. package/templates/development/README.md +9 -9
  161. package/templates/development/cross-repo-debugging.md +3 -3
  162. package/templates/development/external-context/service-template.md +1 -1
  163. package/templates/development/external-source-packs/README.md +2 -2
  164. package/templates/integrations/README.md +4 -4
  165. package/templates/integrations/api-contract.md +1 -1
  166. package/templates/integrations/event-contract.md +1 -1
  167. package/templates/integrations/third-party/vendor-template.md +1 -1
  168. package/templates/integrations/webhook-contract.md +1 -1
  169. package/templates/ledger/Harness-Ledger.md +1 -1
  170. package/templates/modules/module_brief.md +50 -0
  171. package/templates/modules/module_plan.md +49 -0
  172. package/templates/modules/registry_view.md +9 -0
  173. package/templates/modules/session_prompt_pack.md +55 -0
  174. package/templates/planning/brief.md +32 -8
  175. package/templates/planning/module_brief.md +28 -3
  176. package/templates/planning/module_plan.md +26 -11
  177. package/templates/planning/module_session_prompt.md +11 -2
  178. package/templates/planning/optional/slices/_slice-template/brief.md +28 -0
  179. package/templates/planning/review.md +1 -1
  180. package/templates/planning/visual_map.md +1 -1
  181. package/templates/reference/docs-library-standard.md +7 -7
  182. package/templates/reference/execution-workflow-standard.md +13 -0
  183. package/templates/reference/external-source-intake-standard.md +10 -10
  184. package/templates/reference/repo-governance-standard.md +1 -1
  185. package/templates/reference/review-routing-standard.md +4 -0
  186. package/templates/ssot/Module-Registry.md +4 -38
  187. package/templates/walkthrough/walkthrough-template.md +1 -1
  188. package/templates-zh-CN/AGENTS.md.template +27 -25
  189. package/templates-zh-CN/CLAUDE.md.template +1 -1
  190. package/templates-zh-CN/architecture/README.md +2 -2
  191. package/templates-zh-CN/architecture/service-catalog.md +2 -2
  192. package/templates-zh-CN/architecture/services/service-template.md +1 -1
  193. package/templates-zh-CN/development/README.md +9 -9
  194. package/templates-zh-CN/development/cross-repo-debugging.md +3 -3
  195. package/templates-zh-CN/development/external-context/service-template.md +1 -1
  196. package/templates-zh-CN/development/external-source-packs/README.md +2 -2
  197. package/templates-zh-CN/integrations/README.md +4 -4
  198. package/templates-zh-CN/integrations/api-contract.md +1 -1
  199. package/templates-zh-CN/integrations/event-contract.md +1 -1
  200. package/templates-zh-CN/integrations/third-party/vendor-template.md +1 -1
  201. package/templates-zh-CN/integrations/webhook-contract.md +1 -1
  202. package/templates-zh-CN/ledger/Harness-Ledger.md +1 -1
  203. package/templates-zh-CN/lessons/lesson-arch-process-change.md +1 -1
  204. package/templates-zh-CN/lessons/lesson-new-doc.md +3 -3
  205. package/templates-zh-CN/lessons/lesson-ref-change.md +4 -4
  206. package/templates-zh-CN/modules/module_brief.md +47 -0
  207. package/templates-zh-CN/modules/module_plan.md +48 -0
  208. package/templates-zh-CN/modules/registry_view.md +9 -0
  209. package/templates-zh-CN/modules/session_prompt_pack.md +50 -0
  210. package/templates-zh-CN/planning/INDEX.md +1 -0
  211. package/templates-zh-CN/planning/brief.md +26 -7
  212. package/templates-zh-CN/planning/module_brief.md +24 -2
  213. package/templates-zh-CN/planning/module_plan.md +35 -29
  214. package/templates-zh-CN/planning/module_session_prompt.md +15 -11
  215. package/templates-zh-CN/planning/optional/slices/_slice-template/brief.md +28 -11
  216. package/templates-zh-CN/planning/review.md +1 -1
  217. package/templates-zh-CN/reference/adversarial-review-standard.md +1 -1
  218. package/templates-zh-CN/reference/delivery-operating-model-standard.md +3 -3
  219. package/templates-zh-CN/reference/docs-library-standard.md +27 -27
  220. package/templates-zh-CN/reference/execution-workflow-standard.md +12 -2
  221. package/templates-zh-CN/reference/external-source-intake-standard.md +10 -10
  222. package/templates-zh-CN/reference/harness-ledger-standard.md +3 -3
  223. package/templates-zh-CN/reference/regression-ssot-governance.md +2 -2
  224. package/templates-zh-CN/reference/repo-governance-standard.md +1 -1
  225. package/templates-zh-CN/reference/review-routing-standard.md +3 -0
  226. package/templates-zh-CN/reference/walkthrough-standard.md +2 -2
  227. package/templates-zh-CN/reference/worktree-standard.md +1 -1
  228. package/templates-zh-CN/regression/Cadence-Ledger.md +2 -2
  229. package/templates-zh-CN/ssot/Delivery-SSoT.md +2 -2
  230. package/templates-zh-CN/ssot/Module-Registry.md +5 -44
  231. package/templates-zh-CN/ssot/Regression-SSoT.md +2 -2
  232. package/templates-zh-CN/walkthrough/walkthrough-template.md +4 -4
@@ -7,7 +7,7 @@ let labels = window.HarnessI18n?.[locale] || {};
7
7
  const state = {
8
8
  query: "",
9
9
  taskState: "all",
10
- taskGroupMode: "migration",
10
+ taskGroupMode: localStorage.getItem("harness.taskGroupMode") || ((Array.isArray(bundle.modules) && bundle.modules.length > 0) ? "module" : "migration"),
11
11
  taskPageByGroup: {},
12
12
  taskGroupPage: 1,
13
13
  warningFilter: "all",
@@ -24,6 +24,10 @@ const state = {
24
24
  presetSeedForce: false,
25
25
  presetUninstallScope: "project",
26
26
  presetUninstallConfirm: "",
27
+ reviewBulkSelection: {},
28
+ reviewBulkResult: null,
29
+ lessonBulkSelection: {},
30
+ lessonBulkResult: null,
27
31
  renderMode: "rendered",
28
32
  theme: localStorage.getItem("harness.theme") || "system",
29
33
  taskLayout: localStorage.getItem("harness.taskLayout") || "list",
@@ -57,6 +61,10 @@ function t(key) {
57
61
  return labels[key] || key;
58
62
  }
59
63
 
64
+ function formatMessage(key, values = {}) {
65
+ return escapeHtml(t(key)).replace(/\{([^}]+)\}/g, (_, name) => escapeHtml(values[name] ?? ""));
66
+ }
67
+
60
68
  function setLocale(nextLocale) {
61
69
  locale = window.HarnessI18n?.[nextLocale] ? nextLocale : "en";
62
70
  labels = window.HarnessI18n?.[locale] || {};
@@ -83,6 +91,7 @@ function shell() {
83
91
  ${routeLink("#/", t("overview"), "overview")}
84
92
  ${routeLink("#/tasks", t("taskIndex"), "tasks")}
85
93
  ${routeLink("#/review", t("reviewQueue"), "review")}
94
+ ${routeLink("#/archive", t("archive"), "archive")}
86
95
  ${routeLink("#/modules", t("moduleView"), "modules")}
87
96
  ${routeLink("#/presets", t("presetCatalog"), "presets")}
88
97
  <button data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
@@ -110,6 +119,7 @@ function renderRoute() {
110
119
  if (route.name === "task") return taskDetail(route);
111
120
  if (route.name === "reviewTask") return reviewWorkspace(route);
112
121
  if (route.name === "review") return reviewQueue();
122
+ if (route.name === "archive") return archiveView();
113
123
  if (route.name === "modules") return modulesView(route.id);
114
124
  if (route.name === "presets") return presetsView();
115
125
  if (route.name === "tasks") return taskIndex();
@@ -122,6 +132,7 @@ function currentRoute() {
122
132
  if (parts[0] === "tasks" && parts[1]) return { name: "task", id: parts[1], doc: parts[2] === "docs" ? parts[3] || "" : "" };
123
133
  if (parts[0] === "review" && parts[1]) return { name: "reviewTask", id: parts[1] };
124
134
  if (parts[0] === "review") return { name: "review" };
135
+ if (parts[0] === "archive") return { name: "archive" };
125
136
  if (parts[0] === "modules") return { name: "modules", id: parts[1] || "" };
126
137
  if (parts[0] === "presets") return { name: "presets" };
127
138
  if (parts[0] === "tasks") return { name: "tasks" };
@@ -157,7 +168,7 @@ function statusStrip() {
157
168
  const displayState = snapshotOnly ? "snapshot" : status;
158
169
  const failures = bundle.status?.checkState?.failures || 0;
159
170
  const warnings = bundle.status?.checkState?.warnings || 0;
160
- const tasks = bundle.status?.tasks || [];
171
+ const tasks = normalCycleTasks();
161
172
  const summary = bundle.status?.summary || {};
162
173
  const visual = summary.visualMapCoverage || {};
163
174
  const withBrief = tasks.filter((task) => task.briefSource === "standalone").length;
@@ -189,7 +200,7 @@ function nextActionText() {
189
200
  if (dataOnly && !isWorkbenchRuntime()) return t("snapshotNotValidated");
190
201
  const failures = bundle.status?.checkState?.failures || 0;
191
202
  if (failures > 0) return t("resolveBlockers");
192
- const missingBriefs = (bundle.status?.tasks || []).filter((task) => task.briefSource !== "standalone").length;
203
+ const missingBriefs = normalCycleTasks().filter((task) => task.briefSource !== "standalone").length;
193
204
  if (missingBriefs > 0) return `${missingBriefs} ${t("missingBriefs")}`;
194
205
  const warnings = bundle.status?.checkState?.warnings || 0;
195
206
  if (warnings > 0) return t("reviewAdvice");
@@ -202,7 +213,7 @@ function isWorkbenchRuntime() {
202
213
  }
203
214
 
204
215
  function flowPanel() {
205
- const tasks = bundle.status?.tasks || [];
216
+ const tasks = normalCycleTasks();
206
217
  const total = tasks.length;
207
218
  if (total === 0) return "";
208
219
  const active = tasks.filter((task) => isActiveTaskState(task.state)).length;
@@ -261,14 +272,14 @@ function projectMermaid() {
261
272
 
262
273
  function usesAggregateFlow() {
263
274
  const graph = bundle.graph || { nodes: [], edges: [] };
264
- const taskCount = (bundle.status?.tasks || []).length;
275
+ const taskCount = normalCycleTasks().length;
265
276
  const taskNodes = (graph.nodes || []).filter((node) => node.type === "task").length;
266
277
  const usefulEdges = (graph.edges || []).filter((edge) => ["depends_on", "current_step"].includes(edge.type)).length;
267
278
  return taskCount > 80 || taskNodes > 80 || ((graph.nodes || []).length > 80 && usefulEdges < 6);
268
279
  }
269
280
 
270
281
  function migrationAggregateMermaid() {
271
- const tasks = bundle.status?.tasks || [];
282
+ const tasks = normalCycleTasks();
272
283
  const warnings = warningQueue();
273
284
  const activeContracts = warnings.filter((warning) => warning.phase === "active-task-contracts").length;
274
285
  const moduleCount = new Set(tasks.map(taskModuleKey)).size;
@@ -284,7 +295,7 @@ function migrationAggregateMermaid() {
284
295
  }
285
296
 
286
297
  function migrationRunwayBreakdown() {
287
- const tasks = bundle.status?.tasks || [];
298
+ const tasks = normalCycleTasks();
288
299
  const warnings = warningQueue();
289
300
  const phases = [
290
301
  ["baseline", t("runwayBaseline"), tasks.length, t("tasks"), "#/tasks"],
@@ -306,7 +317,7 @@ function mermaidFromBriefs() {
306
317
 
307
318
  function graphSummary() {
308
319
  const graph = bundle.graph || { nodes: [], edges: [] };
309
- if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${(bundle.status?.tasks || []).length} ${t("tasks")}`;
320
+ if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${normalCycleTasks().length} ${t("tasks")}`;
310
321
  return `${graph.nodes?.length || 0} ${t("nodes")} · ${graph.edges?.length || 0} ${t("edges")}`;
311
322
  }
312
323
 
@@ -330,7 +341,7 @@ function activeTaskBriefs() {
330
341
  }
331
342
 
332
343
  function activeTasks() {
333
- const tasks = bundle.status?.tasks || [];
344
+ const tasks = normalCycleTasks();
334
345
  const active = tasks.filter((task) => isActiveTaskState(task.state) || ["planned", "not_started"].includes(task.state));
335
346
  if (active.length > 0) return sortTasksByTime(active);
336
347
  return sortTasksByTime(tasks.filter((task) => task.briefSource === "standalone"));
@@ -396,6 +407,22 @@ function stateToColorVar(state) {
396
407
  return map[state] || "--muted";
397
408
  }
398
409
 
410
+ function taskStatRows(tasks) {
411
+ return [
412
+ { state: "in_progress", label: t("statInProgress"), className: "in-progress" },
413
+ { state: "review", label: t("statReview"), className: "review" },
414
+ { state: "blocked", label: t("statBlocked"), className: "blocked" },
415
+ { state: "done", label: t("statDone"), className: "done" },
416
+ { state: "planned", label: label("planned"), className: "planned" },
417
+ { state: "not_started", label: label("not_started"), className: "not-started" },
418
+ { state: "unknown", label: label("unknown"), className: "unknown" },
419
+ ].map((row) => ({
420
+ ...row,
421
+ count: tasks.filter((task) => task.state === row.state).length,
422
+ colorVar: stateToColorVar(row.state),
423
+ })).filter((row) => row.count > 0);
424
+ }
425
+
399
426
  function taskSortLabel() {
400
427
  return state.taskSortOrder === "asc" ? t("sortOldest") : t("sortNewest");
401
428
  }
@@ -430,6 +457,23 @@ function sortTasksByTime(tasks) {
430
457
  return [...tasks].sort(compareTasksByTime);
431
458
  }
432
459
 
460
+ function isArchivedTask(task) {
461
+ const archiveState = String(task?.archiveMetadata?.state || "").toLowerCase();
462
+ return task?.deletionState === "archived" || archiveState === "archived";
463
+ }
464
+
465
+ function normalCycleTasks() {
466
+ return (bundle.status?.tasks || []).filter((task) => !isArchivedTask(task));
467
+ }
468
+
469
+ function archivedTasks() {
470
+ return (bundle.status?.tasks || []).filter(isArchivedTask);
471
+ }
472
+
473
+ function archiveBucket(task) {
474
+ return task?.archiveMetadata?.["retention bucket"] || task?.archiveMetadata?.["Retention Bucket"] || t("archiveUnclassified");
475
+ }
476
+
433
477
  function taskFolderName(task) {
434
478
  const fromPath = String(task?.path || "").split("/").filter(Boolean).pop();
435
479
  const fromId = String(task?.id || "").split("/").filter(Boolean).pop();
@@ -458,7 +502,7 @@ function taskToolbarCard(filteredCount) {
458
502
  <div class="select-group">
459
503
  <label>${t("stateFilter")}</label>
460
504
  <select data-state-filter aria-label="${t("stateFilter")}">
461
- ${["all", "in_progress", "review", "blocked", "planned", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
505
+ ${["all", "in_progress", "review", "blocked", "planned", "not_started", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
462
506
  </select>
463
507
  </div>
464
508
  <div class="select-group">
@@ -478,6 +522,10 @@ function taskToolbarCard(filteredCount) {
478
522
  <svg style="width:12px;height:12px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
479
523
  ${t("layoutGrid")}
480
524
  </button>
525
+ <button class="layout-btn ${state.taskLayout === "swimlane" ? "active" : ""}" data-layout="swimlane" aria-label="${t("layoutSwimlane")}">
526
+ <svg style="width:12px;height:12px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/><path d="M8 4v16"/><path d="M16 4v16"/></svg>
527
+ ${t("layoutSwimlane")}
528
+ </button>
481
529
  </div>
482
530
  </div>
483
531
  <div class="select-group">
@@ -492,13 +540,13 @@ function taskToolbarCard(filteredCount) {
492
540
  </div>
493
541
  </div>
494
542
  <div class="search-stats">
495
- ${t("showing")} <strong>${filteredCount}</strong> / ${(bundle.status?.tasks || []).length} ${t("tasks")}
543
+ ${t("showing")} <strong>${filteredCount}</strong> / ${normalCycleTasks().length} ${t("tasks")}
496
544
  </div>
497
545
  </section>`;
498
546
  }
499
547
 
500
548
  function taskStatsCard() {
501
- const allTasks = bundle.status?.tasks || [];
549
+ const allTasks = normalCycleTasks();
502
550
  const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
503
551
  return `<section class="sidebar-card">
504
552
  <h3>${t("releaseHealth")}</h3>
@@ -507,13 +555,7 @@ function taskStatsCard() {
507
555
  <span class="gauge-label">${t("statOverall")}</span>
508
556
  </div>
509
557
  <div class="stats-breakdown">
510
- ${[
511
- { state: "in_progress", label: t("statInProgress"), colorVar: "--accent" },
512
- { state: "review", label: t("statReview"), colorVar: "--accent-2" },
513
- { state: "blocked", label: t("statBlocked"), colorVar: "--danger" },
514
- { state: "done", label: t("statDone"), colorVar: "--ok" }
515
- ].map(({ state, label, colorVar }) => {
516
- const count = allTasks.filter(t => t.state === state).length;
558
+ ${taskStatRows(allTasks).map(({ label, colorVar, count }) => {
517
559
  return `<div class="stats-breakdown-row">
518
560
  <span class="stat-label">
519
561
  <span class="state-dot" style="background:var(${colorVar})"></span>
@@ -549,11 +591,7 @@ function taskLegendCard() {
549
591
  }
550
592
 
551
593
  function taskStatsBar() {
552
- const allTasks = bundle.status?.tasks || [];
553
- const inProgress = allTasks.filter(t => t.state === "in_progress").length;
554
- const blocked = allTasks.filter(t => t.state === "blocked").length;
555
- const done = allTasks.filter(t => t.state === "done").length;
556
- const review = allTasks.filter(t => t.state === "review").length;
594
+ const allTasks = normalCycleTasks();
557
595
  const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
558
596
 
559
597
  return `<section class="task-stats-bar">
@@ -561,22 +599,10 @@ function taskStatsBar() {
561
599
  <span class="stat-value">${allTasks.length}</span>
562
600
  <span class="stat-label">${t("statTotal")}</span>
563
601
  </div>
564
- <div class="stat-chip in-progress">
565
- <span class="stat-value">${inProgress}</span>
566
- <span class="stat-label">${t("statInProgress")}</span>
567
- </div>
568
- <div class="stat-chip review">
569
- <span class="stat-value">${review}</span>
570
- <span class="stat-label">${t("statReview")}</span>
571
- </div>
572
- <div class="stat-chip blocked">
573
- <span class="stat-value">${blocked}</span>
574
- <span class="stat-label">${t("statBlocked")}</span>
575
- </div>
576
- <div class="stat-chip done">
577
- <span class="stat-value">${done}</span>
578
- <span class="stat-label">${t("statDone")}</span>
579
- </div>
602
+ ${taskStatRows(allTasks).map((row) => `<div class="stat-chip ${escapeAttr(row.className)}" style="--stat-color: var(${row.colorVar})">
603
+ <span class="stat-value">${row.count}</span>
604
+ <span class="stat-label">${escapeHtml(row.label)}</span>
605
+ </div>`).join("")}
580
606
  <div class="stat-chip completion">
581
607
  <div class="stat-bar-track"><div class="stat-bar-fill" style="width:${avgCompletion}%"></div></div>
582
608
  <div style="text-align:right">
@@ -593,12 +619,14 @@ function taskRow(task) {
593
619
  const mapReady = !!taskDocument(task, "visual_map.md");
594
620
  const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
595
621
  const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
622
+ const moduleLabel = taskModuleLabel(task);
623
+ const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
596
624
 
597
625
  return `<article class="task-row-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)})">
598
626
  <div class="row-accent-bar"></div>
599
627
  <div class="row-main">
600
628
  <strong>${escapeHtml(task.title)}</strong>
601
- <span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(taskModuleKey(task))}</span>
629
+ <span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(moduleLabel)}${lifecycle ? ` · ${escapeHtml(lifecycle)}` : ""}</span>
602
630
  ${taskCopyButton(task, "row-copy")}
603
631
  </div>
604
632
  <div class="row-status">${tag(task.state)}</div>
@@ -628,15 +656,16 @@ function taskIndex() {
628
656
  const groupPageCount = Math.max(1, Math.ceil(orderedGroups.length / taskGroupsPerPage));
629
657
  const groupPage = Math.min(Math.max(1, Number(state.taskGroupPage) || 1), groupPageCount);
630
658
  const visibleGroups = orderedGroups.slice((groupPage - 1) * taskGroupsPerPage, groupPage * taskGroupsPerPage);
659
+ const swimlane = state.taskLayout === "swimlane";
631
660
 
632
661
  return `<div class="tasks-grid">
633
662
  <div class="tasks-main stack">
634
663
  ${taskStatsBar()}
635
- ${visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
636
- <section class="group-pager">
664
+ ${swimlane ? taskSwimlane(tasks) : visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
665
+ ${swimlane ? "" : `<section class="group-pager">
637
666
  <span>${t("showingGroups")} ${visibleGroups.length ? (groupPage - 1) * taskGroupsPerPage + 1 : 0}-${Math.min(groupPage * taskGroupsPerPage, orderedGroups.length)} / ${orderedGroups.length}</span>
638
667
  ${pager("task-groups", groupPage, groupPageCount)}
639
- </section>
668
+ </section>`}
640
669
  </div>
641
670
  <aside class="tasks-sidebar stack">
642
671
  ${taskToolbarCard(tasks.length)}
@@ -699,6 +728,7 @@ function taskGroup(group, tasks) {
699
728
  const start = (page - 1) * taskPageSize;
700
729
  const visibleTasks = orderedTasks.slice(start, start + taskPageSize);
701
730
  const avgCompletion = orderedTasks.length ? clampCompletion(orderedTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / orderedTasks.length) : 0;
731
+ const groupContext = taskGroupContext(group, orderedTasks);
702
732
 
703
733
  const isGrid = state.taskLayout === "grid";
704
734
  const layoutClass = isGrid ? "task-card-grid" : "task-list";
@@ -714,8 +744,10 @@ function taskGroup(group, tasks) {
714
744
  return `<section class="task-group">
715
745
  <div class="section-head">
716
746
  <div>
717
- <h2>${taskGroupLabel(group)}</h2>
718
- <p class="subtle">${t("showing")} ${Math.min(start + 1, orderedTasks.length)}-${Math.min(start + visibleTasks.length, orderedTasks.length)} / ${orderedTasks.length}</p>
747
+ <p class="eyebrow">${escapeHtml(groupContext.eyebrow)}</p>
748
+ <h2>${escapeHtml(groupContext.title)}</h2>
749
+ <p class="subtle">${escapeHtml(groupContext.summary)} · ${t("showing")} ${Math.min(start + 1, orderedTasks.length)}-${Math.min(start + visibleTasks.length, orderedTasks.length)} / ${orderedTasks.length}</p>
750
+ ${groupContext.chips.length ? `<div class="module-chip-row">${groupContext.chips.map((chip) => `<span class="module-chip">${escapeHtml(chip)}</span>`).join("")}</div>` : ""}
719
751
  </div>
720
752
  <div class="group-actions">
721
753
  <div class="group-progress" aria-label="${escapeAttr(t("groupCompletion"))}">
@@ -739,6 +771,7 @@ function taskCard(task) {
739
771
  const mapReady = !!taskDocument(task, "visual_map.md");
740
772
  const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
741
773
  const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
774
+ const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
742
775
 
743
776
  return `<article class="task-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateColor})">
744
777
  <div class="card-header">
@@ -752,8 +785,9 @@ function taskCard(task) {
752
785
  <div class="card-meta">
753
786
  <span class="meta-module" title="${escapeAttr(taskModuleKey(task))}">
754
787
  <svg style="width:12px;height:12px;vertical-align:middle;margin-right:2px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
755
- ${escapeHtml(taskModuleKey(task))}
788
+ ${escapeHtml(taskModuleLabel(task))}
756
789
  </span>
790
+ ${lifecycle ? `<span class="meta-lifecycle" title="${escapeAttr(lifecycle)}">${escapeHtml(lifecycle)}</span>` : ""}
757
791
  </div>
758
792
  <div class="card-progress">
759
793
  <div class="card-progress-track"><div class="card-progress-fill" style="width:${completion}%"></div></div>
@@ -776,7 +810,7 @@ function taskGroupLabel(group) {
776
810
  if (group === "active") return t("activeCurrent");
777
811
  if (group === "brief-ready") return t("briefReadyGroup");
778
812
  if (group.startsWith("legacy:")) return `${t("legacyMonth")} ${group.slice("legacy:".length)}`;
779
- if (group.startsWith("module:")) return `${t("inferredModule")} · ${group.slice("module:".length)}`;
813
+ if (group.startsWith("module:")) return taskGroupContext(group, []).title;
780
814
  if (group.startsWith("month:")) return `${t("legacyMonth")} ${group.slice("month:".length)}`;
781
815
  if (group.startsWith("state:")) return `${t("columnState")} · ${label(group.slice("state:".length))}`;
782
816
  return label(group);
@@ -784,7 +818,7 @@ function taskGroupLabel(group) {
784
818
 
785
819
  function filteredTasks() {
786
820
  const query = state.query.trim().toLowerCase();
787
- return sortTasksByTime((bundle.status?.tasks || []).filter((task) => {
821
+ return sortTasksByTime(normalCycleTasks().filter((task) => {
788
822
  const stateMatch = state.taskState === "all" || task.state === state.taskState;
789
823
  if (!stateMatch) return false;
790
824
  if (!query) return true;
@@ -796,6 +830,390 @@ function taskModuleKey(task) {
796
830
  return task.module || task.inferredModule || "legacy-unclassified";
797
831
  }
798
832
 
833
+ function taskModuleDisplayLabel(key) {
834
+ if (key === "base") return t("baseModule");
835
+ if (key === "legacy-unclassified") return t("unclassifiedModule");
836
+ return key;
837
+ }
838
+
839
+ function archiveView() {
840
+ const tasks = sortTasksByTime(archivedTasks());
841
+ const groups = Object.entries(groupBy(tasks, archiveBucket)).sort(([left], [right]) => left.localeCompare(right));
842
+ return `<main class="stack archive-view">
843
+ <section class="flow-panel">
844
+ <div class="section-head">
845
+ <div>
846
+ <p class="eyebrow">${t("archive")}</p>
847
+ <h2>${t("archiveView")}</h2>
848
+ <p class="subtle">${t("archiveSubtitle")}</p>
849
+ </div>
850
+ <a href="#/tasks">${t("openTaskIndex")}</a>
851
+ </div>
852
+ <div class="archive-summary-grid">
853
+ ${metric(t("archivedTasks"), tasks.length)}
854
+ ${metric(t("archiveBuckets"), groups.length)}
855
+ </div>
856
+ </section>
857
+ ${groups.map(([bucket, bucketTasks]) => archiveGroup(bucket, bucketTasks)).join("") || emptyState(t("noArchivedTasks"))}
858
+ </main>`;
859
+ }
860
+
861
+ function archiveGroup(bucket, tasks) {
862
+ const orderedTasks = sortTasksByTime(tasks);
863
+ return `<section class="archive-group">
864
+ <div class="section-head">
865
+ <div>
866
+ <h2>${escapeHtml(bucket)}</h2>
867
+ <p class="subtle">${orderedTasks.length} ${t("tasks")}</p>
868
+ </div>
869
+ </div>
870
+ <div class="archive-task-list">
871
+ ${orderedTasks.map(archiveTaskRow).join("")}
872
+ </div>
873
+ </section>`;
874
+ }
875
+
876
+ function archiveTaskRow(task) {
877
+ const archiveMetadata = task.archiveMetadata || {};
878
+ const archivedBy = archiveMetadata?.["archived by"] || t("unknown");
879
+ const archivedAt = archiveMetadata?.["archived at"] || "";
880
+ const reviewConfirmedBy = archiveMetadata?.["review confirmed by"] || t("unknown");
881
+ const reviewConfirmedAt = archiveMetadata?.["review confirmed at"] || "";
882
+ const reviewConfirmationId = archiveMetadata?.["review confirmation id"] || "";
883
+ const releasePackage = archiveMetadata?.["release package"] || "";
884
+ const reason = task.deleteReason || archiveMetadata?.reason || "";
885
+ return `<article class="archive-task-row">
886
+ <div class="archive-task-main">
887
+ <a href="#/tasks/${encodeURIComponent(task.id)}">${escapeHtml(task.title || task.id)}</a>
888
+ <span>${escapeHtml(task.id)}</span>
889
+ ${reason ? `<p>${escapeHtml(reason)}</p>` : ""}
890
+ </div>
891
+ <dl class="archive-meta-grid">
892
+ <div><dt>${t("archivedBy")}</dt><dd>${escapeHtml(archivedBy)}</dd></div>
893
+ <div><dt>${t("archivedAt")}</dt><dd>${escapeHtml(archivedAt || t("unknown"))}</dd></div>
894
+ <div><dt>${t("reviewConfirmedBy")}</dt><dd>${escapeHtml(reviewConfirmedBy)}</dd></div>
895
+ <div><dt>${t("reviewConfirmedAt")}</dt><dd>${escapeHtml(reviewConfirmedAt || t("unknown"))}</dd></div>
896
+ ${reviewConfirmationId ? `<div><dt>${t("reviewConfirmationId")}</dt><dd>${escapeHtml(reviewConfirmationId)}</dd></div>` : ""}
897
+ ${releasePackage ? `<div><dt>${t("releasePackage")}</dt><dd>${escapeHtml(releasePackage)}</dd></div>` : ""}
898
+ </dl>
899
+ </article>`;
900
+ }
901
+
902
+ const swimlaneStageOrder = [
903
+ ["planned", "swimlaneStagePlanned"],
904
+ ["in_progress", "swimlaneStageInProgress"],
905
+ ["evidence", "swimlaneStageEvidence"],
906
+ ["review", "swimlaneStageReview"],
907
+ ["confirmed", "swimlaneStageConfirmed"],
908
+ ["closeout", "swimlaneStageCloseout"],
909
+ ["blocked", "swimlaneStageBlocked"],
910
+ ];
911
+ const swimlaneCellPageSize = 10;
912
+ const swimlaneMiniColumnLimit = 5;
913
+
914
+ function taskSwimlaneModel(tasks) {
915
+ const cards = sortTasksByTime(tasks)
916
+ .filter((task) => taskVisibleInSwimlane(task))
917
+ .map((task) => {
918
+ const lane = taskModuleKey(task);
919
+ const stage = taskSwimlaneStage(task);
920
+ return {
921
+ task,
922
+ lane,
923
+ stage,
924
+ id: task.id,
925
+ title: task.title,
926
+ reason: taskSwimlaneReason(task),
927
+ };
928
+ });
929
+ const laneKeys = [...new Set(cards.map((card) => card.lane))].sort((left, right) => {
930
+ if (left === "legacy-unclassified") return 1;
931
+ if (right === "legacy-unclassified") return -1;
932
+ return left.localeCompare(right);
933
+ });
934
+ return {
935
+ stages: swimlaneStageOrder.map(([key, labelKey]) => ({ key, label: t(labelKey) })),
936
+ lanes: laneKeys.map((key) => ({ key, label: taskModuleDisplayLabel(key) })),
937
+ cards,
938
+ };
939
+ }
940
+
941
+ function taskVisibleInSwimlane(task) {
942
+ const stateValue = String(task.state || "");
943
+ const closeout = String(task.closeoutStatus || "");
944
+ if (["done", "closed", "finalized"].includes(stateValue)) return false;
945
+ if (["closed", "finalized"].includes(closeout)) return false;
946
+ if (clampCompletion(task.completion) >= 100 && !["review", "blocked", "reopened", "current-evidence"].includes(stateValue)) return false;
947
+ return ["active", "planned", "not_started", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(stateValue)
948
+ || ["ready-to-confirm", "needs-material", "review-blocked"].includes(String(task.reviewQueueState || ""))
949
+ || ["agent-reviewed", "confirmed", "blocked-open-findings"].includes(String(task.reviewStatus || ""));
950
+ }
951
+
952
+ function taskSwimlaneStage(task) {
953
+ const stateValue = String(task.state || "");
954
+ const review = String(task.reviewStatus || "");
955
+ const reviewQueue = String(task.reviewQueueState || "");
956
+ const closeout = String(task.closeoutStatus || "");
957
+ if (stateValue === "blocked" || review.includes("blocked") || reviewQueue.includes("blocked")) return "blocked";
958
+ if (review === "confirmed" && taskHasPendingLessonWork(task)) return "closeout";
959
+ if (review === "confirmed" && ["missing", "pending", "required", "closing"].includes(closeout)) return "closeout";
960
+ if (review === "confirmed") return "confirmed";
961
+ if (stateValue === "review" || reviewQueue === "ready-to-confirm" || (task.taskQueues || []).includes("review") || ["agent-reviewed", "in_review"].includes(review)) return "review";
962
+ if (["planned", "not_started"].includes(stateValue)) return "planned";
963
+ if (taskNeedsEvidence(task)) return "evidence";
964
+ if (["active", "in_progress", "reopened", "current-evidence"].includes(stateValue)) return "in_progress";
965
+ return "planned";
966
+ }
967
+
968
+ function taskNeedsEvidence(task) {
969
+ if (["missing", "legacy-only"].includes(String(task.visualMapStatus || ""))) return true;
970
+ if (task.briefSource && task.briefSource !== "standalone") return true;
971
+ return (task.phases || []).some((phase) => ["missing", "partial"].includes(String(phase.evidenceStatus || "")));
972
+ }
973
+
974
+ function taskSwimlaneReason(task) {
975
+ const reasons = Array.isArray(task.queueReasons) ? task.queueReasons.filter(Boolean) : [];
976
+ if (reasons.length) return reasons[0];
977
+ if (taskNeedsEvidence(task)) return t("swimlaneNeedsEvidence");
978
+ if (task.reviewQueueState === "ready-to-confirm") return t("swimlaneReadyToConfirm");
979
+ if (task.closeoutStatus === "missing") return t("swimlaneNeedsCloseout");
980
+ return "";
981
+ }
982
+
983
+ function taskSwimlane(tasks) {
984
+ const model = taskSwimlaneModel(tasks);
985
+ if (!model.cards.length) return `<section class="task-swimlane empty-state">${escapeHtml(t("swimlaneEmpty"))}</section>`;
986
+ const view = taskSwimlaneHeatmapModel(model);
987
+ const active = taskSwimlaneActiveExpansion(view);
988
+ return `<section class="task-swimlane" aria-label="${escapeAttr(t("layoutSwimlane"))}">
989
+ <div class="swimlane-header">
990
+ <div>
991
+ <p class="eyebrow">${t("swimlaneEyebrow")}</p>
992
+ <h2>${t("swimlaneTitle")}</h2>
993
+ </div>
994
+ <span class="subtle">${model.cards.length} · ${t("tasks")}</span>
995
+ </div>
996
+ ${taskSwimlaneHeatmap(view, active)}
997
+ ${taskSwimlaneMobileList(view, active)}
998
+ ${taskSwimlaneDrilldown(view, active)}
999
+ </section>`;
1000
+ }
1001
+
1002
+ function taskSwimlaneHeatmapModel(model) {
1003
+ const stageTotals = Object.fromEntries(model.stages.map((stage) => [stage.key, 0]));
1004
+ const lanes = taskSwimlaneRenderLanes(model).map((lane) => {
1005
+ const stageCards = Object.fromEntries(model.stages.map((stage) => [stage.key, []]));
1006
+ for (const card of model.cards) {
1007
+ if (card.lane !== lane.key) continue;
1008
+ stageCards[card.stage] = stageCards[card.stage] || [];
1009
+ stageCards[card.stage].push(card);
1010
+ stageTotals[card.stage] = (stageTotals[card.stage] || 0) + 1;
1011
+ }
1012
+ const total = Object.values(stageCards).reduce((sum, cards) => sum + cards.length, 0);
1013
+ return { ...lane, total, stageCards };
1014
+ }).sort((left, right) => {
1015
+ if (left.key === "legacy-unclassified") return 1;
1016
+ if (right.key === "legacy-unclassified") return -1;
1017
+ const totalDiff = right.total - left.total;
1018
+ return totalDiff || left.label.localeCompare(right.label);
1019
+ });
1020
+ const total = model.cards.length;
1021
+ const columnTemplate = model.stages.map((stage) => {
1022
+ const count = stageTotals[stage.key] || 0;
1023
+ if (count === 0) return "minmax(44px, 0.36fr)";
1024
+ if (count <= 3) return "minmax(74px, 0.7fr)";
1025
+ if (count <= 7) return "minmax(88px, 1fr)";
1026
+ return "minmax(104px, 1.16fr)";
1027
+ }).join(" ");
1028
+ return { stages: model.stages, lanes, stageTotals, total, columnTemplate };
1029
+ }
1030
+
1031
+ function taskSwimlaneRenderLanes(model) {
1032
+ const lanes = new Map(model.lanes.map((lane) => [lane.key, { ...lane }]));
1033
+ const modules = typeof dashboardModules === "function" ? dashboardModules() : [];
1034
+ for (const module of modules) {
1035
+ const key = String(module.key || "").trim();
1036
+ if (!key || key === "legacy-unclassified") continue;
1037
+ const label = key === "base" ? taskModuleDisplayLabel(key) : String(module.title || taskModuleDisplayLabel(key) || key);
1038
+ lanes.set(key, { ...(lanes.get(key) || { key }), key, label });
1039
+ }
1040
+ return [...lanes.values()];
1041
+ }
1042
+
1043
+ function taskSwimlaneHeatmap(view, active) {
1044
+ const style = `--swimlane-stage-columns: ${escapeAttr(view.columnTemplate)}`;
1045
+ return `<div class="swimlane-heatmap" data-swimlane-heatmap="true" style="${style}" aria-label="${escapeAttr(t("swimlaneHeatmapLabel"))}">
1046
+ <div class="swimlane-heatmap-row swimlane-heatmap-head">
1047
+ <div class="swimlane-axis-label">${escapeHtml(t("swimlaneModuleColumn"))}</div>
1048
+ ${view.stages.map((stage) => {
1049
+ const total = view.stageTotals[stage.key] || 0;
1050
+ return `<div class="swimlane-stage-header" data-swimlane-stage-total="${escapeAttr(stage.key)}" data-total="${total}">
1051
+ <span>${escapeHtml(stage.label)}</span>
1052
+ <strong>${total}</strong>
1053
+ </div>`;
1054
+ }).join("")}
1055
+ <div class="swimlane-total-header">${escapeHtml(t("swimlaneTotalColumn"))}</div>
1056
+ </div>
1057
+ ${view.lanes.map((lane) => taskSwimlaneHeatmapRow(lane, view, active)).join("")}
1058
+ </div>`;
1059
+ }
1060
+
1061
+ function taskSwimlaneHeatmapRow(lane, view, active) {
1062
+ const laneActive = active?.mode === "lane" && active.lane === lane.key;
1063
+ return `<div class="swimlane-heatmap-row" data-swimlane-row="${escapeAttr(lane.key)}" data-swimlane-row-total="${lane.total}">
1064
+ <button class="swimlane-lane-button ${laneActive ? "active" : ""}" type="button" data-swimlane-expand="lane" data-lane="${escapeAttr(lane.key)}" aria-expanded="${laneActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel">
1065
+ <strong>${escapeHtml(lane.label)}</strong>
1066
+ <span>${lane.total}</span>
1067
+ </button>
1068
+ ${view.stages.map((stage) => taskSwimlaneHeatmapCell(lane, stage, active)).join("")}
1069
+ <div class="swimlane-row-total"><strong>${lane.total}</strong></div>
1070
+ </div>`;
1071
+ }
1072
+
1073
+ function taskSwimlaneHeatmapCell(lane, stage, active) {
1074
+ const cards = lane.stageCards[stage.key] || [];
1075
+ const count = cards.length;
1076
+ const cellActive = active?.mode === "cell" && active.lane === lane.key && active.stage === stage.key;
1077
+ const disabled = count === 0 ? " disabled" : "";
1078
+ const label = `${lane.label} · ${stage.label} · ${count} ${t("tasks")}`;
1079
+ return `<button class="swimlane-heat-cell heat-${taskSwimlaneHeatLevel(count)} ${cellActive ? "active" : ""}" type="button" data-swimlane-expand="cell" data-lane="${escapeAttr(lane.key)}" data-swimlane-stage="${escapeAttr(stage.key)}" data-count="${count}" aria-label="${escapeAttr(label)}" aria-expanded="${cellActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel"${disabled}>
1080
+ <span>${count}</span>
1081
+ </button>`;
1082
+ }
1083
+
1084
+ function taskSwimlaneMobileList(view, active) {
1085
+ return `<div class="swimlane-mobile-list" aria-label="${escapeAttr(t("swimlaneHeatmapLabel"))}">
1086
+ ${view.lanes.map((lane) => {
1087
+ const laneActive = active?.mode === "lane" && active.lane === lane.key;
1088
+ return `<button class="swimlane-mobile-module ${laneActive ? "active" : ""}" type="button" data-swimlane-expand="lane" data-lane="${escapeAttr(lane.key)}" aria-expanded="${laneActive ? "true" : "false"}" aria-controls="swimlane-drilldown-panel">
1089
+ <span><strong>${escapeHtml(lane.label)}</strong><small>${lane.total} · ${t("tasks")}</small></span>
1090
+ <span class="swimlane-mobile-stages">${view.stages.map((stage) => {
1091
+ const count = (lane.stageCards[stage.key] || []).length;
1092
+ return count ? `<em>${escapeHtml(stage.label)} ${count}</em>` : "";
1093
+ }).join("")}</span>
1094
+ </button>`;
1095
+ }).join("")}
1096
+ </div>`;
1097
+ }
1098
+
1099
+ function taskSwimlaneDrilldown(view, active) {
1100
+ if (!active) return `<div class="swimlane-drilldown-host" data-swimlane-drilldown-host="true"></div>`;
1101
+ const lane = view.lanes.find((candidate) => candidate.key === active.lane);
1102
+ if (!lane) return `<div class="swimlane-drilldown-host" data-swimlane-drilldown-host="true"></div>`;
1103
+ const cards = active.mode === "cell" ? (lane.stageCards[active.stage] || []) : Object.values(lane.stageCards).flat();
1104
+ const title = active.mode === "cell"
1105
+ ? `${lane.label} · ${view.stages.find((stage) => stage.key === active.stage)?.label || active.stage}`
1106
+ : lane.label;
1107
+ return `<div class="swimlane-drilldown-host open" data-swimlane-drilldown-host="true">
1108
+ <section class="swimlane-drilldown" id="swimlane-drilldown-panel" aria-label="${escapeAttr(t("swimlaneDrilldownLabel"))}">
1109
+ <div class="swimlane-drilldown-head">
1110
+ <div>
1111
+ <p class="eyebrow">${escapeHtml(t("swimlaneDrilldownLabel"))}</p>
1112
+ <h3>${escapeHtml(title)}</h3>
1113
+ </div>
1114
+ <div class="swimlane-drilldown-actions">
1115
+ <span>${cards.length} · ${t("tasks")}</span>
1116
+ <button type="button" data-swimlane-collapse>${escapeHtml(t("swimlaneCollapse"))}</button>
1117
+ </div>
1118
+ </div>
1119
+ ${active.mode === "lane" ? taskSwimlaneLaneBoard(lane, view.stages) : taskSwimlanePagedCardList(cards, active.page || 0)}
1120
+ </section>
1121
+ </div>`;
1122
+ }
1123
+
1124
+ function taskSwimlaneLaneBoard(lane, stages) {
1125
+ return `<div class="swimlane-mini-board">
1126
+ ${stages.map((stage) => {
1127
+ const cards = lane.stageCards[stage.key] || [];
1128
+ const visibleCards = cards.slice(0, swimlaneMiniColumnLimit);
1129
+ const hidden = Math.max(0, cards.length - visibleCards.length);
1130
+ return `<div class="swimlane-mini-column">
1131
+ <div class="swimlane-mini-column-head"><span>${escapeHtml(stage.label)}</span><strong>${cards.length}</strong></div>
1132
+ <div class="swimlane-card-list">${visibleCards.map((card) => taskSwimlaneCard(card)).join("") || `<span class="swimlane-mini-empty">${escapeHtml(t("none"))}</span>`}</div>
1133
+ ${hidden ? `<button class="swimlane-stage-drilldown" type="button" data-swimlane-expand="cell" data-swimlane-stage-drilldown="${escapeAttr(stage.key)}" data-lane="${escapeAttr(lane.key)}" data-swimlane-stage="${escapeAttr(stage.key)}">
1134
+ <span>+${hidden}</span>
1135
+ <strong>${escapeHtml(t("swimlaneViewStage"))}</strong>
1136
+ </button>` : ""}
1137
+ </div>`;
1138
+ }).join("")}
1139
+ </div>`;
1140
+ }
1141
+
1142
+ function taskSwimlanePagedCardList(cards, page) {
1143
+ const total = cards.length;
1144
+ const pageCount = Math.max(1, Math.ceil(total / swimlaneCellPageSize));
1145
+ const safePage = Math.max(0, Math.min(pageCount - 1, Number(page) || 0));
1146
+ const start = safePage * swimlaneCellPageSize;
1147
+ const end = Math.min(total, start + swimlaneCellPageSize);
1148
+ const visibleCards = cards.slice(start, end);
1149
+ return `<div class="swimlane-paged-list">
1150
+ <div class="swimlane-card-list">${visibleCards.map((card) => taskSwimlaneCard(card)).join("")}</div>
1151
+ ${total > swimlaneCellPageSize ? `<div class="swimlane-pager" data-swimlane-page="${safePage}" aria-label="${escapeAttr(t("swimlanePageLabel"))}">
1152
+ <button type="button" data-swimlane-page-action="prev" data-page="${safePage - 1}" ${safePage <= 0 ? "disabled" : ""}>${escapeHtml(t("swimlanePrevPage"))}</button>
1153
+ <span>${start + 1}-${end} / ${total}</span>
1154
+ <button type="button" data-swimlane-page-action="next" data-page="${safePage + 1}" ${safePage >= pageCount - 1 ? "disabled" : ""}>${escapeHtml(t("swimlaneNextPage"))}</button>
1155
+ </div>` : ""}
1156
+ </div>`;
1157
+ }
1158
+
1159
+ function taskSwimlaneCard(card) {
1160
+ const task = card.task;
1161
+ const completion = clampCompletion(task.completion);
1162
+ return `<article class="swimlane-card ${escapeAttr(card.stage)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)}); --task-progress: ${completion}%">
1163
+ <span class="swimlane-status-dot" aria-hidden="true"></span>
1164
+ <strong>${escapeHtml(task.title)}</strong>
1165
+ <span class="swimlane-progress" aria-label="${completion}%"><i></i></span>
1166
+ </article>`;
1167
+ }
1168
+
1169
+ function taskSwimlaneHeatLevel(count) {
1170
+ if (count <= 0) return 0;
1171
+ if (count <= 3) return 1;
1172
+ if (count <= 7) return 2;
1173
+ return 3;
1174
+ }
1175
+
1176
+ function taskSwimlaneActiveExpansion(view) {
1177
+ if (!swimlaneExpansion) return null;
1178
+ const lane = view.lanes.find((candidate) => candidate.key === swimlaneExpansion.lane);
1179
+ if (!lane) return null;
1180
+ if (swimlaneExpansion.mode === "cell" && !view.stages.some((stage) => stage.key === swimlaneExpansion.stage)) return null;
1181
+ if (swimlaneExpansion.mode !== "cell") return swimlaneExpansion;
1182
+ const count = (lane.stageCards[swimlaneExpansion.stage] || []).length;
1183
+ const pageCount = Math.max(1, Math.ceil(count / swimlaneCellPageSize));
1184
+ const page = Math.max(0, Math.min(pageCount - 1, Number(swimlaneExpansion.page) || 0));
1185
+ return { ...swimlaneExpansion, page };
1186
+ }
1187
+
1188
+ let swimlaneExpansion = null;
1189
+
1190
+ if (typeof window !== "undefined" && typeof document !== "undefined" && typeof document.addEventListener === "function" && !window.__HARNESS_SWIMLANE_BOUND__) {
1191
+ window.__HARNESS_SWIMLANE_BOUND__ = true;
1192
+ document.addEventListener("click", (event) => {
1193
+ const collapse = event.target.closest?.("[data-swimlane-collapse]");
1194
+ const pager = event.target.closest?.("[data-swimlane-page-action]");
1195
+ const trigger = event.target.closest?.("[data-swimlane-expand]");
1196
+ if (!collapse && !pager && !trigger) return;
1197
+ event.preventDefault();
1198
+ if (collapse) {
1199
+ swimlaneExpansion = null;
1200
+ app();
1201
+ return;
1202
+ }
1203
+ if (pager && swimlaneExpansion?.mode === "cell") {
1204
+ swimlaneExpansion = { ...swimlaneExpansion, page: Number(pager.dataset.page) || 0 };
1205
+ app();
1206
+ return;
1207
+ }
1208
+ const mode = trigger.dataset.swimlaneExpand;
1209
+ const lane = trigger.dataset.lane;
1210
+ const stage = trigger.dataset.swimlaneStage || "";
1211
+ const same = swimlaneExpansion?.mode === mode && swimlaneExpansion?.lane === lane && (swimlaneExpansion?.stage || "") === stage;
1212
+ swimlaneExpansion = same ? null : { mode, lane, stage, page: 0 };
1213
+ app();
1214
+ });
1215
+ }
1216
+
799
1217
  function taskDetail(route) {
800
1218
  const taskId = route.id;
801
1219
  const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
@@ -1024,26 +1442,41 @@ function reviewActionPanel(task, { mode = "summary" } = {}) {
1024
1442
  if (!isTaskInReviewQueue(task)) return "";
1025
1443
  const blocking = task.reviewStatus === "blocked-open-findings" || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
1026
1444
  const confirmed = task.reviewStatus === "confirmed";
1445
+ const readyForCloseout = taskReadyForCloseout(task);
1446
+ const hasLessonWork = taskHasPendingLessonWork(task);
1027
1447
  const candidateBlocked = task.budget !== "simple" && !task.lessonCandidateDecisionComplete;
1028
1448
  const candidateStatus = task.lessonCandidateStatus || "missing";
1029
1449
  if (mode !== "workspace") {
1450
+ const summaryMessage = confirmed && hasLessonWork ? t("reviewConfirmedLessonPending") : confirmed && readyForCloseout ? t("reviewConfirmedCloseoutReady") : confirmed ? t("reviewAlreadyConfirmed") : t("reviewOpenInWorkspace");
1030
1451
  return `<section class="side-panel review-actions">
1031
1452
  <h3>${t("reviewActions")}</h3>
1032
- <p>${escapeHtml(confirmed ? t("reviewAlreadyConfirmed") : t("reviewOpenInWorkspace"))}</p>
1453
+ <p>${escapeHtml(summaryMessage)}</p>
1033
1454
  <p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
1034
1455
  <a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
1035
1456
  </section>`;
1036
1457
  }
1037
- if (!canUseWorkbenchAction("review-complete")) {
1458
+ if (confirmed) {
1459
+ if (hasLessonWork) {
1460
+ return `<section class="side-panel review-actions">
1461
+ <h3>${t("reviewActions")}</h3>
1462
+ <p>${escapeHtml(t("reviewConfirmedLessonPending"))}</p>
1463
+ <p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
1464
+ ${lessonCandidatePanel(task, { context: "detail", limit: 3 })}
1465
+ </section>`;
1466
+ }
1467
+ const closeoutDisabled = !readyForCloseout || !canUseWorkbenchAction("task-complete");
1038
1468
  return `<section class="side-panel review-actions">
1039
1469
  <h3>${t("reviewActions")}</h3>
1040
- <p>${escapeHtml(t("staticReadOnlyDetail"))}</p>
1470
+ <p>${escapeHtml(readyForCloseout ? t("reviewConfirmedCloseoutReady") : t("reviewAlreadyConfirmed"))}</p>
1471
+ <p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
1472
+ <button data-task-complete="${escapeAttr(task.id)}" ${closeoutDisabled ? "disabled" : ""}>${t("completeTaskCloseout")}</button>
1473
+ <div class="review-result" data-task-complete-result="${escapeAttr(task.id)}"></div>
1041
1474
  </section>`;
1042
1475
  }
1043
- if (confirmed) {
1476
+ if (!canUseWorkbenchAction("review-complete")) {
1044
1477
  return `<section class="side-panel review-actions">
1045
1478
  <h3>${t("reviewActions")}</h3>
1046
- <p>${escapeHtml(t("reviewAlreadyConfirmed"))}</p>
1479
+ <p>${escapeHtml(t("staticReadOnlyDetail"))}</p>
1047
1480
  </section>`;
1048
1481
  }
1049
1482
  const missingWalkthrough = task.budget !== "simple" && !task.walkthroughPath;
@@ -1075,6 +1508,21 @@ function taskCanBeHumanConfirmed(task) {
1075
1508
  return task?.reviewQueueState === "ready-to-confirm" && Array.isArray(task?.taskQueues) && task.taskQueues.includes("review");
1076
1509
  }
1077
1510
 
1511
+ function taskHasPendingLessonWork(task) {
1512
+ const queues = Array.isArray(task?.taskQueues) ? task.taskQueues : [];
1513
+ const candidates = Array.isArray(task?.lessonCandidateRows) ? task.lessonCandidateRows : [];
1514
+ return queues.includes("lessons")
1515
+ || task?.lessonCandidateStatus === "needs-promotion"
1516
+ || task?.lessonCandidatePromotionState === "queued"
1517
+ || candidates.some((candidate) => ["ready-for-review", "needs-promotion"].includes(String(candidate?.status || "")));
1518
+ }
1519
+
1520
+ function taskReadyForCloseout(task) {
1521
+ if (!task || task.reviewStatus !== "confirmed" || task.closeoutStatus === "closed") return false;
1522
+ if (taskHasPendingLessonWork(task)) return false;
1523
+ return ["no-candidate-accepted", "promoted", "rejected"].includes(String(task.lessonCandidateStatus || ""));
1524
+ }
1525
+
1078
1526
  function evidenceList(task) {
1079
1527
  const evidence = task.evidence || [];
1080
1528
  return `<section class="side-panel">
@@ -1083,63 +1531,279 @@ function evidenceList(task) {
1083
1531
  </section>`;
1084
1532
  }
1085
1533
 
1534
+ function dashboardModules() {
1535
+ const structured = Array.isArray(bundle.modules) ? bundle.modules : [];
1536
+ if (structured.length > 0) return structured;
1537
+ const graphModules = (bundle.graph?.nodes || []).filter((node) => node.type === "module").map((node) => ({
1538
+ key: String(node.id || "").replace(/^module:/, ""),
1539
+ title: node.label,
1540
+ status: node.state,
1541
+ currentStep: node.currentStep,
1542
+ source: "graph",
1543
+ briefPath: node.briefPath,
1544
+ modulePlanPath: node.modulePlanPath,
1545
+ })).filter((module) => module.key);
1546
+ return graphModules;
1547
+ }
1548
+
1549
+ function moduleDefinition(key) {
1550
+ return dashboardModules().find((module) => module.key === key) || null;
1551
+ }
1552
+
1553
+ function taskModuleLabel(task) {
1554
+ const key = taskModuleKey(task);
1555
+ if (key === "base" || key === "legacy-unclassified") return taskModuleDisplayLabel(key);
1556
+ return moduleDefinition(key)?.title || key;
1557
+ }
1558
+
1559
+ function taskGroupContext(group, tasks) {
1560
+ if (group.startsWith("module:")) {
1561
+ const key = group.slice("module:".length);
1562
+ const counts = moduleCountsForTasks(tasks);
1563
+ if (key === "base") {
1564
+ return {
1565
+ eyebrow: t("baseModuleEyebrow"),
1566
+ title: t("baseModule"),
1567
+ summary: `${tasks.length} ${t("tasks")} · ${counts.active} ${t("active")} · ${counts.review} ${t("statReview")} · ${counts.blocked} ${t("statBlocked")}`,
1568
+ chips: [`${tasks.length} ${t("tasks")}`, `${counts.risk} ${t("moduleRisks")}`],
1569
+ };
1570
+ }
1571
+ if (key === "legacy-unclassified") {
1572
+ return {
1573
+ eyebrow: t("unclassifiedWarning"),
1574
+ title: t("unclassifiedModule"),
1575
+ summary: t("unclassifiedSummary").replace("{count}", String(tasks.length)),
1576
+ chips: [`${tasks.length} ${t("tasks")}`, `${counts.risk} ${t("moduleRisks")}`],
1577
+ };
1578
+ }
1579
+ const module = moduleDefinition(key) || { key, title: key, source: "inferred" };
1580
+ const chips = [
1581
+ module.status ? `${t("columnState")}: ${label(module.status)}` : "",
1582
+ module.owner ? `${t("moduleOwner")}: ${module.owner}` : "",
1583
+ module.currentStep ? `${t("moduleCurrentStep")}: ${module.currentStep}` : "",
1584
+ module.dependsOn?.length ? `${t("moduleDependsOn")}: ${module.dependsOn.join(", ")}` : "",
1585
+ module.scope?.length ? `${t("moduleScope")}: ${module.scope.join(", ")}` : "",
1586
+ ].filter(Boolean);
1587
+ return {
1588
+ eyebrow: module.source === "registry" ? t("registeredModule") : t("inferredModule"),
1589
+ title: module.title || key,
1590
+ summary: `${tasks.length} ${t("tasks")} · ${counts.active} ${t("active")} · ${counts.review} ${t("statReview")} · ${counts.blocked} ${t("statBlocked")}`,
1591
+ chips,
1592
+ };
1593
+ }
1594
+ return {
1595
+ eyebrow: t("groupBy"),
1596
+ title: taskGroupLabel(group),
1597
+ summary: `${tasks.length} ${t("tasks")}`,
1598
+ chips: [],
1599
+ };
1600
+ }
1601
+
1602
+ function moduleCountsForTasks(tasks) {
1603
+ return {
1604
+ active: tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)).length,
1605
+ review: tasks.filter((task) => task.state === "review").length,
1606
+ blocked: tasks.filter((task) => task.state === "blocked").length,
1607
+ risk: tasks.filter(uiDashboardTaskHasRisk).length,
1608
+ };
1609
+ }
1610
+
1086
1611
  function modulesView(moduleId = "") {
1087
- const graph = bundle.graph || { nodes: [], edges: [] };
1088
- const explicitModules = (graph.nodes || []).filter((node) => node.type === "module");
1089
- const moduleMap = new Map(explicitModules.map((module) => [module.id.replace(/^module:/, ""), module]));
1090
- for (const task of bundle.status?.tasks || []) {
1612
+ const modules = modulesWithTaskFallback();
1613
+ const selectedKey = moduleId || state.selectedModuleKey || modules[0]?.key || "";
1614
+ state.selectedModuleKey = selectedKey;
1615
+ const selected = modules.find((module) => module.key === selectedKey) || modules[0] || null;
1616
+ const unclassified = normalCycleTasks().filter((task) => taskModuleKey(task) === "legacy-unclassified");
1617
+ return `<main class="stack module-console">
1618
+ ${moduleRunStrip(modules, unclassified)}
1619
+ <section class="module-console-grid">
1620
+ <nav class="module-list-panel" aria-label="${escapeAttr(t("moduleView"))}">
1621
+ ${modules.map((module) => moduleListItem(module, selected?.key === module.key)).join("") || emptyState(t("noModules"))}
1622
+ </nav>
1623
+ <article class="module-detail-panel">
1624
+ ${selected ? moduleDetail(selected) : emptyState(t("noModules"))}
1625
+ </article>
1626
+ </section>
1627
+ ${unclassified.length ? moduleUnclassifiedPanel(unclassified) : ""}
1628
+ </main>`;
1629
+ }
1630
+
1631
+ function modulesWithTaskFallback() {
1632
+ const moduleMap = new Map(dashboardModules().map((module) => [module.key, {
1633
+ ...module,
1634
+ counts: emptyUiModuleCounts(),
1635
+ tasks: [],
1636
+ }]));
1637
+ for (const task of normalCycleTasks()) {
1091
1638
  const key = taskModuleKey(task);
1092
- if (!moduleMap.has(key)) moduleMap.set(key, { id: `module:${key}`, type: "module", label: key, state: task.classificationSource || "inferred" });
1639
+ if (key === "legacy-unclassified") continue;
1640
+ if (!moduleMap.has(key)) {
1641
+ moduleMap.set(key, {
1642
+ key,
1643
+ title: taskModuleDisplayLabel(key),
1644
+ source: key === "base" ? "structure" : "inferred",
1645
+ status: task.classificationSource || "inferred",
1646
+ counts: emptyUiModuleCounts(),
1647
+ tasks: [],
1648
+ });
1649
+ }
1650
+ const module = moduleMap.get(key);
1651
+ accumulateUiModuleTask(module, task);
1652
+ }
1653
+ return [...moduleMap.values()].sort((left, right) => {
1654
+ const leftActive = Number(left.counts?.active || 0);
1655
+ const rightActive = Number(right.counts?.active || 0);
1656
+ if (leftActive !== rightActive) return rightActive - leftActive;
1657
+ return left.key.localeCompare(right.key);
1658
+ });
1659
+ }
1660
+
1661
+ function emptyUiModuleCounts() {
1662
+ return { total: 0, active: 0, review: 0, blocked: 0, risk: 0, missingDocs: 0 };
1663
+ }
1664
+
1665
+ function accumulateUiModuleTask(module, task) {
1666
+ if (!module || !task) return;
1667
+ const stateValue = String(task.state || "unknown");
1668
+ if (!module.tasks.some((item) => item.id === task.id)) module.tasks.push(task);
1669
+ module.counts.total = (module.counts.total || 0) + 1;
1670
+ if (["in_progress", "review", "blocked", "planned", "not_started"].includes(stateValue)) {
1671
+ module.counts.active = (module.counts.active || 0) + 1;
1672
+ }
1673
+ if (stateValue !== "active") module.counts[stateValue] = (module.counts[stateValue] || 0) + 1;
1674
+ if (uiDashboardTaskHasRisk(task)) {
1675
+ module.counts.risk = (module.counts.risk || 0) + 1;
1093
1676
  }
1094
- const modules = [...moduleMap.values()];
1095
- return `<main class="stack">
1096
- <section class="module-grid">
1097
- ${modules.map((module) => moduleCard(module)).join("") || emptyState(t("noModules"))}
1677
+ if (task.briefSource && task.briefSource !== "standalone") {
1678
+ module.counts.missingDocs = (module.counts.missingDocs || 0) + 1;
1679
+ }
1680
+ }
1681
+
1682
+ function uiDashboardTaskHasRisk(task) {
1683
+ if (task.state === "blocked") return true;
1684
+ if (String(task.reviewStatus || "").includes("blocked")) return true;
1685
+ if (Array.isArray(task.materialIssues) && task.materialIssues.length > 0) return true;
1686
+ if (Array.isArray(task.queueReasons) && task.queueReasons.length > 0) return true;
1687
+ if (String(task.visualMapStatus || "") === "missing") return true;
1688
+ return false;
1689
+ }
1690
+
1691
+ function moduleRunStrip(modules, unclassified) {
1692
+ const active = modules.filter((module) => Number(module.counts?.active || 0) > 0).length;
1693
+ const risk = modules.reduce((sum, module) => sum + Number(module.counts?.risk || 0), 0);
1694
+ const registered = modules.filter((module) => module.source === "registry").length;
1695
+ return `<section class="module-run-strip">
1696
+ ${metric(t("moduleRegistered"), registered)}
1697
+ ${metric(t("moduleActive"), active)}
1698
+ ${metric(t("moduleRisks"), risk)}
1699
+ ${metric(t("moduleUnclassified"), unclassified.length)}
1700
+ </section>`;
1701
+ }
1702
+
1703
+ function moduleListItem(module, active) {
1704
+ const counts = module.counts || emptyUiModuleCounts();
1705
+ return `<a class="module-list-item ${active ? "active" : ""}" href="#/modules/${encodeURIComponent(module.key)}" data-module-select="${escapeAttr(module.key)}">
1706
+ <span>
1707
+ <strong>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</strong>
1708
+ <small>${escapeHtml(module.key)} · ${escapeHtml(module.source || "registry")}</small>
1709
+ </span>
1710
+ <span class="module-list-counts">
1711
+ <b>${Number(counts.active || 0)}</b>
1712
+ ${tag(module.status || "planned")}
1713
+ </span>
1714
+ </a>`;
1715
+ }
1716
+
1717
+ function moduleDetail(module) {
1718
+ const tasks = normalCycleTasks().filter((task) => taskModuleKey(task) === module.key);
1719
+ const activeTasks = tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state));
1720
+ const riskTasks = tasks.filter((task) => task.state === "blocked" || String(task.reviewStatus || "").includes("blocked") || String(task.visualMapStatus || "") === "missing");
1721
+ const brief = findDocument(module.briefPath || `TARGET:coding-agent-harness/planning/modules/${module.key}/brief.md`);
1722
+ const plan = findDocument(module.modulePlanPath || "");
1723
+ return `<div class="module-detail-stack">
1724
+ <header class="module-detail-header">
1725
+ <div>
1726
+ <p class="eyebrow">${escapeHtml(module.key === "base" ? t("baseModuleEyebrow") : module.source === "registry" ? t("registeredModule") : t("inferredModule"))}</p>
1727
+ <h2>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</h2>
1728
+ <p class="subtle">${escapeHtml(module.key)}${module.currentStep ? ` · ${escapeHtml(module.currentStep)}` : ""}</p>
1729
+ </div>
1730
+ ${tag(module.status || "planned")}
1731
+ </header>
1732
+ <div class="module-chip-row">
1733
+ ${module.owner ? `<span class="module-chip">${t("moduleOwner")}: ${escapeHtml(module.owner)}</span>` : ""}
1734
+ ${module.branch ? `<span class="module-chip">${t("moduleBranch")}: ${escapeHtml(module.branch)}</span>` : ""}
1735
+ ${module.dependsOn?.length ? `<span class="module-chip">${t("moduleDependsOn")}: ${escapeHtml(module.dependsOn.join(", "))}</span>` : ""}
1736
+ </div>
1737
+ <section class="module-boundary-grid">
1738
+ ${moduleBoundaryBlock(t("moduleScope"), module.scope)}
1739
+ ${moduleBoundaryBlock(t("moduleShared"), module.shared)}
1740
+ ${moduleBoundaryBlock(t("moduleDependsOn"), module.dependsOn)}
1098
1741
  </section>
1099
- </main>`;
1742
+ <section class="module-work-panel">
1743
+ <div class="section-head">
1744
+ <div>
1745
+ <h3>${t("moduleCurrentWork")}</h3>
1746
+ <p class="subtle">${activeTasks.length} ${t("active")} · ${riskTasks.length} ${t("moduleRisks")}</p>
1747
+ </div>
1748
+ <a href="#/tasks">${t("openTaskIndex")}</a>
1749
+ </div>
1750
+ <div class="module-task-list">
1751
+ ${activeTasks.slice(0, 10).map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
1752
+ </div>
1753
+ </section>
1754
+ ${riskTasks.length ? `<section class="module-risk-panel">
1755
+ <h3>${t("moduleRiskPanel")}</h3>
1756
+ <div class="module-task-list">${riskTasks.slice(0, 8).map(moduleTaskRow).join("")}</div>
1757
+ </section>` : ""}
1758
+ <section class="module-doc-panel">
1759
+ <h3>${t("sourceDocuments")}</h3>
1760
+ <div class="module-doc-links">
1761
+ ${moduleDocLink(t("brief"), module.briefPath, brief)}
1762
+ ${moduleDocLink(t("taskPlan"), module.modulePlanPath, plan)}
1763
+ </div>
1764
+ <div class="markdown module-doc-preview">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
1765
+ </section>
1766
+ </div>`;
1767
+ }
1768
+
1769
+ function moduleBoundaryBlock(title, values) {
1770
+ const items = Array.isArray(values) && values.length ? values : [t("none")];
1771
+ return `<div class="module-boundary-block">
1772
+ <strong>${escapeHtml(title)}</strong>
1773
+ ${items.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}
1774
+ </div>`;
1775
+ }
1776
+
1777
+ function moduleDocLink(labelText, pathValue, document) {
1778
+ if (!pathValue && !document) return `<span class="module-doc-link missing">${escapeHtml(labelText)} · ${t("documentMissing")}</span>`;
1779
+ return `<span class="module-doc-link">${escapeHtml(labelText)} · ${escapeHtml(pathValue || document.path || t("ready"))}</span>`;
1100
1780
  }
1101
1781
 
1102
1782
  function moduleTaskRow(task) {
1103
1783
  const dotClass = /fail|blocked|open/i.test(task.state) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(task.state) ? "state-warn" : "state-pass";
1784
+ const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
1104
1785
  return `<a class="module-task-row" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}">
1105
1786
  <div class="module-task-left">
1106
1787
  <i class="module-task-dot ${dotClass}" title="${escapeAttr(task.state)}"></i>
1107
- <span class="module-task-title">${escapeHtml(task.title)}</span>
1788
+ <span class="module-task-title">${escapeHtml(task.title || task.id)}</span>
1789
+ ${lifecycle ? `<small>${escapeHtml(lifecycle)}</small>` : ""}
1108
1790
  </div>
1109
- <span class="module-task-pct">${task.completion}%</span>
1791
+ <span class="module-task-pct">${clampCompletion(task.completion)}%</span>
1110
1792
  </a>`;
1111
1793
  }
1112
1794
 
1113
- function moduleCard(module) {
1114
- const moduleKey = module.id.replace(/^module:/, "");
1115
- const tasks = (bundle.status?.tasks || []).filter((task) => taskModuleKey(task) === moduleKey);
1116
-
1117
- // Inline Pagination
1118
- state.modulePages = state.modulePages || {};
1119
- const currentPage = state.modulePages[moduleKey] || 1;
1120
- const pageCount = Math.ceil(tasks.length / 8) || 1;
1121
- const visibleTasks = tasks.slice((currentPage - 1) * 8, currentPage * 8);
1122
-
1123
- const brief = findDocument(module.briefPath || `TARGET:coding-agent-harness/planning/modules/${moduleKey}/brief.md`);
1124
-
1125
- let pagerHtml = "";
1126
- if (tasks.length > 8) {
1127
- pagerHtml = `<div class="module-pager">
1128
- <button ${currentPage <= 1 ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage - 1})">${t("prevPage")}</button>
1129
- <span>${currentPage} / ${pageCount}</span>
1130
- <button ${currentPage >= pageCount ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage + 1})">${t("nextPage")}</button>
1131
- </div>`;
1132
- }
1133
-
1134
- return `<article class="module-card">
1135
- <div class="card-head"><h2>${escapeHtml(module.label || moduleKey)}</h2>${tag(module.state || "unknown")}</div>
1136
- <div class="markdown">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
1137
- <h3>${t("moduleTasks")} · ${tasks.length}</h3>
1138
- <div class="module-task-list">
1139
- ${visibleTasks.map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
1795
+ function moduleUnclassifiedPanel(tasks) {
1796
+ return `<section class="module-unclassified-panel">
1797
+ <div class="section-head">
1798
+ <div>
1799
+ <p class="eyebrow">${t("unclassifiedWarning")}</p>
1800
+ <h2>${t("unclassifiedModule")}</h2>
1801
+ <p class="subtle">${t("unclassifiedSummary").replace("{count}", String(tasks.length))}</p>
1802
+ </div>
1803
+ <a href="#/tasks">${t("openTaskIndex")}</a>
1140
1804
  </div>
1141
- ${pagerHtml}
1142
- </article>`;
1805
+ <div class="module-task-list">${tasks.slice(0, 12).map(moduleTaskRow).join("")}</div>
1806
+ </section>`;
1143
1807
  }
1144
1808
 
1145
1809
  function reviewQueue() {
@@ -1150,6 +1814,10 @@ function reviewQueue() {
1150
1814
  const reasonOptions = reviewReasonOptions(baseTasks);
1151
1815
  normalizeReviewReasonFilter(reasonOptions);
1152
1816
  const tasks = reviewFilteredTasks(baseTasks);
1817
+ const confirmableTasks = activeTab.id === "review" ? tasks.filter(taskCanBeHumanConfirmed) : [];
1818
+ syncReviewBulkSelection(confirmableTasks);
1819
+ if (activeTab.id === "lessons") syncLessonBulkSelection(lessonBulkActionableSelections());
1820
+ else syncLessonBulkSelection([]);
1153
1821
  const pageCount = Math.max(1, Math.ceil(tasks.length / taskPageSize));
1154
1822
  const page = Math.min(Math.max(1, Number(state.reviewQueuePage) || 1), pageCount);
1155
1823
  const visibleTasks = tasks.slice((page - 1) * taskPageSize, page * taskPageSize);
@@ -1185,6 +1853,8 @@ function reviewQueue() {
1185
1853
  </select>
1186
1854
  </div>
1187
1855
  </div>
1856
+ ${activeTab.id === "review" ? reviewBulkBar(confirmableTasks) : ""}
1857
+ ${activeTab.id === "lessons" ? lessonBulkBar() : ""}
1188
1858
  <div class="review-queue-list-shell" tabindex="0" aria-label="${escapeAttr(activeTab.label)} ${escapeAttr(t("reviewQueue"))}">
1189
1859
  <div class="review-queue-list">
1190
1860
  ${visibleTasks.map((task) => reviewQueueCard(task, activeTab)).join("") || emptyState(t("noQueueTasks"))}
@@ -1251,7 +1921,7 @@ function reviewSortOptions() {
1251
1921
  }
1252
1922
 
1253
1923
  function reviewQueueBaseTasks(tab) {
1254
- return (bundle.status?.tasks || []).filter((task) => taskMatchesReviewTab(task, tab));
1924
+ return normalCycleTasks().filter((task) => taskMatchesReviewTab(task, tab));
1255
1925
  }
1256
1926
 
1257
1927
  function taskMatchesReviewTab(task, tab) {
@@ -1327,17 +1997,57 @@ function reviewTruthyCount(tasks, key) {
1327
1997
  return tasks.filter((task) => task[key] === true).length;
1328
1998
  }
1329
1999
 
2000
+ function reviewBulkSelectedIds() {
2001
+ return Object.entries(state.reviewBulkSelection || {})
2002
+ .filter(([, selected]) => selected === true)
2003
+ .map(([taskId]) => taskId);
2004
+ }
2005
+
2006
+ function syncReviewBulkSelection(confirmableTasks) {
2007
+ const allowed = new Set(confirmableTasks.map((task) => task.id));
2008
+ for (const taskId of Object.keys(state.reviewBulkSelection || {})) {
2009
+ if (!allowed.has(taskId)) delete state.reviewBulkSelection[taskId];
2010
+ }
2011
+ }
2012
+
2013
+ function reviewBulkBar(confirmableTasks) {
2014
+ const selectedCount = reviewBulkSelectedIds().length;
2015
+ const allSelected = confirmableTasks.length > 0 && confirmableTasks.every((task) => state.reviewBulkSelection?.[task.id] === true);
2016
+ const disabled = selectedCount === 0 || !canUseWorkbenchAction("review-complete-bulk");
2017
+ const result = state.reviewBulkResult ? `<span class="bulk-action-result ${state.reviewBulkResult.ok ? "success" : "failed"}">${escapeHtml(state.reviewBulkResult.message)}</span>` : "";
2018
+ return `<div class="bulk-action-bar review-bulk-bar">
2019
+ <label class="bulk-select-all">
2020
+ <input type="checkbox" data-review-bulk-select-all ${allSelected ? "checked" : ""} ${confirmableTasks.length ? "" : "disabled"} aria-label="${escapeAttr(t("selectAllReviewTasks"))}">
2021
+ <span>${t("selectAllReviewTasks")}</span>
2022
+ </label>
2023
+ <span class="bulk-selected-count">${formatMessage("reviewBulkSelected", { count: selectedCount })}</span>
2024
+ <button type="button" data-review-bulk-confirm ${disabled ? "disabled" : ""}>${t("reviewBulkConfirm")}</button>
2025
+ <button type="button" data-review-bulk-clear ${selectedCount ? "" : "disabled"}>${t("clearSelection")}</button>
2026
+ ${result}
2027
+ </div>`;
2028
+ }
2029
+
1330
2030
  function reviewQueueCard(task, tab) {
1331
2031
  const openMaterial = (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
1332
2032
  const reasons = task.queueReasons || [];
1333
2033
  const canCopyRepairPrompt = tab?.repair && String(task.repairPrompt || "").trim();
1334
2034
  const lessonActions = tab?.id === "lessons" ? lessonCandidatePanel(task, { context: "card", limit: 2 }) : "";
2035
+ const closeoutAction = taskReadyForCloseout(task)
2036
+ ? `<button data-task-complete="${escapeAttr(task.id)}" ${canUseWorkbenchAction("task-complete") ? "" : "disabled"}>${t("completeTaskCloseout")}</button><span class="inline-result" data-task-complete-result="${escapeAttr(task.id)}"></span>`
2037
+ : "";
1335
2038
  const displayId = task.shortId || taskFolderName(task) || task.id;
2039
+ const canBulkConfirm = tab?.id === "review" && taskCanBeHumanConfirmed(task);
2040
+ const bulkSelected = state.reviewBulkSelection?.[task.id] === true;
2041
+ const bulkControl = tab?.id === "review" ? `<label class="bulk-card-check">
2042
+ <input type="checkbox" data-review-bulk-select="${escapeAttr(task.id)}" ${canBulkConfirm ? "" : "disabled"} ${bulkSelected ? "checked" : ""} aria-label="${escapeAttr(t("selectReviewTask"))}">
2043
+ <span>${t("select")}</span>
2044
+ </label>` : "";
1336
2045
  return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(task.state)})">
1337
2046
  <div class="card-header">
1338
2047
  <span class="card-id" title="${escapeAttr(task.id)}">${escapeHtml(displayId)}</span>
1339
2048
  ${tag(task.reviewStatus || "missing")}
1340
2049
  ${reviewTaskQueues(task).map(tag).join("")}
2050
+ ${bulkControl}
1341
2051
  </div>
1342
2052
  <h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
1343
2053
  <div class="card-meta">
@@ -1348,23 +2058,39 @@ function reviewQueueCard(task, tab) {
1348
2058
  <span>${t("materialsReady")}: ${task.materialsReady === true ? t("yes") : t("no")}</span>
1349
2059
  </div>
1350
2060
  <p class="subtle">${escapeHtml(firstUsefulLine(task.summary || task.briefText || ""))}</p>
2061
+ ${tombstoneSummary(task)}
1351
2062
  ${reasons.length ? `<div class="review-reasons">${reasons.slice(0, 4).map(reviewReason).join("")}</div>` : ""}
1352
2063
  ${lessonActions}
1353
2064
  <div class="review-queue-actions">
1354
2065
  <a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
1355
2066
  <a href="#/tasks/${encodeURIComponent(task.id)}">${t("fullView")}</a>
1356
2067
  <button data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
2068
+ ${closeoutAction}
1357
2069
  ${tab?.repair ? `<button data-copy-repair-prompt="${escapeAttr(task.id)}" data-repair-prompt="${escapeAttr(task.repairPrompt || "")}" ${canCopyRepairPrompt ? "" : "disabled"}>${t("copyRepairPrompt")}</button>` : ""}
1358
2070
  </div>
1359
2071
  </article>`;
1360
2072
  }
1361
2073
 
2074
+ function tombstoneSummary(task) {
2075
+ const deletionState = String(task?.deletionState || "active");
2076
+ if (deletionState === "active") return "";
2077
+ const reason = String(task?.deleteReason || "").trim();
2078
+ const supersededBy = String(task?.supersededBy || "").trim();
2079
+ return `<div class="review-tombstone-summary">
2080
+ <span>${tag(deletionState)}</span>
2081
+ ${reason ? `<span>${t("reason")}: ${escapeHtml(reason)}</span>` : ""}
2082
+ ${supersededBy ? `<a href="#/tasks/${encodeURIComponent(supersededBy)}">${escapeHtml(supersededBy)}</a>` : ""}
2083
+ </div>`;
2084
+ }
2085
+
1362
2086
  function lessonCandidatePanel(task, { context = "detail", limit = 0 } = {}) {
1363
2087
  const candidates = (task.lessonCandidateRows || []).filter((candidate) => ["ready-for-review", "needs-promotion"].includes(candidate.status));
1364
2088
  if (!candidates.length) return "";
1365
2089
  const visibleCandidates = limit > 0 ? candidates.slice(0, limit) : candidates;
1366
2090
  const hiddenCount = Math.max(0, candidates.length - visibleCandidates.length);
1367
2091
  const staticNote = canUseWorkbenchAction("lesson-sedimentation-task") ? "" : `<p class="lesson-action-note">${escapeHtml(t("lessonWorkbenchRequired"))}</p>`;
2092
+ syncLessonBulkSelection(lessonBulkActionableSelections());
2093
+ const bulkBar = context === "card" ? "" : lessonBulkBar();
1368
2094
  return `<section class="lesson-candidate-panel ${context === "card" ? "compact" : ""}">
1369
2095
  <div class="lesson-candidate-panel-head">
1370
2096
  <div>
@@ -1374,6 +2100,7 @@ function lessonCandidatePanel(task, { context = "detail", limit = 0 } = {}) {
1374
2100
  <span class="tag">${visibleCandidates.length}/${candidates.length}</span>
1375
2101
  </div>
1376
2102
  ${staticNote}
2103
+ ${bulkBar}
1377
2104
  <div class="lesson-candidate-actions">
1378
2105
  ${visibleCandidates.map((candidate) => lessonCandidateAction(task, candidate)).join("")}
1379
2106
  </div>
@@ -1385,6 +2112,9 @@ function lessonCandidateAction(task, candidate) {
1385
2112
  const followUp = String(candidate.followUpTask || "").trim();
1386
2113
  const hasFollowUp = followUp && !/^pending$/i.test(followUp);
1387
2114
  const prompt = lessonSedimentationPrompt(task, candidate);
2115
+ const selectionKey = lessonBulkSelectionKey(task.id, candidate.id);
2116
+ const canBulkCreate = canUseWorkbenchAction("lesson-sedimentation-bulk") && !hasFollowUp;
2117
+ const selected = state.lessonBulkSelection?.[selectionKey] === true;
1388
2118
  return `<div class="lesson-candidate-action">
1389
2119
  <div class="lesson-candidate-main">
1390
2120
  <strong>${escapeHtml(candidate.id)}</strong>
@@ -1393,6 +2123,10 @@ function lessonCandidateAction(task, candidate) {
1393
2123
  </div>
1394
2124
  <span class="review-result" data-lesson-result="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}"></span>
1395
2125
  <div class="lesson-candidate-command-row">
2126
+ <label class="bulk-card-check lesson-bulk-check">
2127
+ <input type="checkbox" data-lesson-bulk-select="${escapeAttr(selectionKey)}" ${canBulkCreate ? "" : "disabled"} ${selected ? "checked" : ""} aria-label="${escapeAttr(t("selectLessonCandidate"))}">
2128
+ <span>${t("select")}</span>
2129
+ </label>
1396
2130
  ${hasFollowUp ? `<a href="#/tasks/${encodeURIComponent(followUp)}">${t("openFollowUpTask")}</a>` : ""}
1397
2131
  <button data-copy-lesson-prompt="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}" data-lesson-prompt="${escapeAttr(prompt)}">${t("copyLessonPrompt")}</button>
1398
2132
  <button data-create-lesson-sedimentation="${escapeAttr(task.id)}" data-candidate-id="${escapeAttr(candidate.id)}" ${canUseWorkbenchAction("lesson-sedimentation-task") && !hasFollowUp ? "" : "disabled"}>${t("createLessonTask")}</button>
@@ -1400,6 +2134,62 @@ function lessonCandidateAction(task, candidate) {
1400
2134
  </div>`;
1401
2135
  }
1402
2136
 
2137
+ function lessonBulkSelectionKey(taskId, candidateId) {
2138
+ return `${taskId}::${candidateId}`;
2139
+ }
2140
+
2141
+ function parseLessonBulkSelectionKey(key) {
2142
+ const separator = String(key || "").lastIndexOf("::");
2143
+ if (separator < 0) return null;
2144
+ const taskId = key.slice(0, separator);
2145
+ const candidateId = key.slice(separator + 2);
2146
+ if (!taskId || !candidateId) return null;
2147
+ return { taskId, candidateId };
2148
+ }
2149
+
2150
+ function lessonBulkSelectedSelections() {
2151
+ return Object.entries(state.lessonBulkSelection || {})
2152
+ .filter(([, selected]) => selected === true)
2153
+ .map(([key]) => parseLessonBulkSelectionKey(key))
2154
+ .filter(Boolean);
2155
+ }
2156
+
2157
+ function lessonBulkActionableSelections() {
2158
+ return (bundle.status?.tasks || []).flatMap((task) => (task.lessonCandidateRows || [])
2159
+ .filter((candidate) => ["ready-for-review", "needs-promotion"].includes(candidate.status))
2160
+ .filter((candidate) => {
2161
+ const followUp = String(candidate.followUpTask || "").trim();
2162
+ return !followUp || /^pending$/i.test(followUp);
2163
+ })
2164
+ .map((candidate) => ({ taskId: task.id, candidateId: candidate.id })));
2165
+ }
2166
+
2167
+ function syncLessonBulkSelection(actionableSelections) {
2168
+ const allowed = new Set(actionableSelections.map((selection) => lessonBulkSelectionKey(selection.taskId, selection.candidateId)));
2169
+ for (const key of Object.keys(state.lessonBulkSelection || {})) {
2170
+ if (!allowed.has(key)) delete state.lessonBulkSelection[key];
2171
+ }
2172
+ }
2173
+
2174
+ function lessonBulkBar() {
2175
+ const actionableSelections = lessonBulkActionableSelections();
2176
+ syncLessonBulkSelection(actionableSelections);
2177
+ const selectedCount = lessonBulkSelectedSelections().length;
2178
+ const allSelected = actionableSelections.length > 0 && actionableSelections.every((selection) => state.lessonBulkSelection?.[lessonBulkSelectionKey(selection.taskId, selection.candidateId)] === true);
2179
+ const disabled = selectedCount === 0 || !canUseWorkbenchAction("lesson-sedimentation-bulk");
2180
+ const result = state.lessonBulkResult ? `<span class="bulk-action-result ${state.lessonBulkResult.ok ? "success" : "failed"}">${escapeHtml(state.lessonBulkResult.message)}</span>` : "";
2181
+ return `<div class="bulk-action-bar lesson-bulk-bar">
2182
+ <label class="bulk-select-all">
2183
+ <input type="checkbox" data-lesson-bulk-select-all ${allSelected ? "checked" : ""} ${actionableSelections.length ? "" : "disabled"} aria-label="${escapeAttr(t("selectAllLessonCandidates"))}">
2184
+ <span>${t("selectAllLessonCandidates")}</span>
2185
+ </label>
2186
+ <span class="bulk-selected-count">${formatMessage("lessonBulkSelected", { count: selectedCount })}</span>
2187
+ <button type="button" data-lesson-bulk-create ${disabled ? "disabled" : ""}>${t("lessonBulkCreate")}</button>
2188
+ <button type="button" data-lesson-bulk-clear ${selectedCount ? "" : "disabled"}>${t("clearSelection")}</button>
2189
+ ${result}
2190
+ </div>`;
2191
+ }
2192
+
1403
2193
  function lessonSedimentationPrompt(task, candidate) {
1404
2194
  return [
1405
2195
  "You are executing a lesson sedimentation follow-up task.",
@@ -2120,12 +2910,26 @@ window.setModulePage = function(moduleKey, page) {
2120
2910
  app();
2121
2911
  };
2122
2912
 
2913
+ function rerenderPreservingFieldFocus(field, selector) {
2914
+ const shouldRestore = document.activeElement === field;
2915
+ const selectionStart = typeof field.selectionStart === "number" ? field.selectionStart : null;
2916
+ const selectionEnd = typeof field.selectionEnd === "number" ? field.selectionEnd : selectionStart;
2917
+ app();
2918
+ if (!shouldRestore) return;
2919
+ const nextField = document.querySelector(selector);
2920
+ if (!nextField || typeof nextField.focus !== "function") return;
2921
+ nextField.focus({ preventScroll: true });
2922
+ if (typeof nextField.setSelectionRange === "function" && selectionStart !== null) {
2923
+ nextField.setSelectionRange(selectionStart, selectionEnd);
2924
+ }
2925
+ }
2926
+
2123
2927
  function bind() {
2124
2928
  document.querySelectorAll("[data-search]").forEach((input) => input.addEventListener("input", () => {
2125
2929
  state.query = input.value;
2126
2930
  state.taskPageByGroup = {};
2127
2931
  state.taskGroupPage = 1;
2128
- app();
2932
+ rerenderPreservingFieldFocus(input, "[data-search]");
2129
2933
  }));
2130
2934
  document.querySelectorAll("[data-state-filter]").forEach((select) => select.addEventListener("change", () => {
2131
2935
  state.taskState = select.value;
@@ -2135,6 +2939,7 @@ function bind() {
2135
2939
  }));
2136
2940
  document.querySelectorAll("[data-group-mode]").forEach((select) => select.addEventListener("change", () => {
2137
2941
  state.taskGroupMode = select.value;
2942
+ localStorage.setItem("harness.taskGroupMode", state.taskGroupMode);
2138
2943
  state.taskPageByGroup = {};
2139
2944
  state.taskGroupPage = 1;
2140
2945
  app();
@@ -2167,7 +2972,7 @@ function bind() {
2167
2972
  }));
2168
2973
  document.querySelectorAll("[data-preset-search]").forEach((input) => input.addEventListener("input", () => {
2169
2974
  state.presetQuery = input.value;
2170
- app();
2975
+ rerenderPreservingFieldFocus(input, "[data-preset-search]");
2171
2976
  }));
2172
2977
  document.querySelectorAll("[data-preset-source-filter]").forEach((button) => button.addEventListener("click", () => {
2173
2978
  state.presetSourceFilter = button.dataset.presetSourceFilter || "all";
@@ -2230,6 +3035,9 @@ function bind() {
2230
3035
  document.querySelectorAll("[data-review-queue-tab]").forEach((button) => button.addEventListener("click", () => {
2231
3036
  state.reviewQueueTab = button.dataset.reviewQueueTab || "review";
2232
3037
  state.reviewQueuePage = 1;
3038
+ state.reviewBulkSelection = {};
3039
+ state.reviewBulkResult = null;
3040
+ state.lessonBulkResult = null;
2233
3041
  app();
2234
3042
  }));
2235
3043
  document.querySelectorAll("[data-review-reason-filter]").forEach((select) => select.addEventListener("change", () => {
@@ -2242,6 +3050,51 @@ function bind() {
2242
3050
  state.reviewQueuePage = 1;
2243
3051
  app();
2244
3052
  }));
3053
+ document.querySelectorAll("[data-review-bulk-select]").forEach((input) => input.addEventListener("change", () => {
3054
+ state.reviewBulkSelection = state.reviewBulkSelection || {};
3055
+ state.reviewBulkSelection[input.dataset.reviewBulkSelect || ""] = input.checked;
3056
+ state.reviewBulkResult = null;
3057
+ app();
3058
+ }));
3059
+ document.querySelectorAll("[data-review-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {
3060
+ const activeTab = reviewQueueTabs().find((tab) => tab.id === state.reviewQueueTab) || reviewQueueTabs()[0];
3061
+ const tasks = activeTab.id === "review" ? reviewFilteredTasks(reviewQueueBaseTasks(activeTab)).filter(taskCanBeHumanConfirmed) : [];
3062
+ state.reviewBulkSelection = state.reviewBulkSelection || {};
3063
+ tasks.forEach((task) => {
3064
+ if (input.checked) state.reviewBulkSelection[task.id] = true;
3065
+ else delete state.reviewBulkSelection[task.id];
3066
+ });
3067
+ state.reviewBulkResult = null;
3068
+ app();
3069
+ }));
3070
+ document.querySelectorAll("[data-review-bulk-clear]").forEach((button) => button.addEventListener("click", () => {
3071
+ state.reviewBulkSelection = {};
3072
+ state.reviewBulkResult = null;
3073
+ app();
3074
+ }));
3075
+ document.querySelectorAll("[data-review-bulk-confirm]").forEach((button) => button.addEventListener("click", () => confirmSelectedReviewsFromDashboard(button)));
3076
+ document.querySelectorAll("[data-lesson-bulk-select]").forEach((input) => input.addEventListener("change", () => {
3077
+ state.lessonBulkSelection = state.lessonBulkSelection || {};
3078
+ state.lessonBulkSelection[input.dataset.lessonBulkSelect || ""] = input.checked;
3079
+ state.lessonBulkResult = null;
3080
+ app();
3081
+ }));
3082
+ document.querySelectorAll("[data-lesson-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {
3083
+ state.lessonBulkSelection = state.lessonBulkSelection || {};
3084
+ lessonBulkActionableSelections().forEach((selection) => {
3085
+ const key = lessonBulkSelectionKey(selection.taskId, selection.candidateId);
3086
+ if (input.checked) state.lessonBulkSelection[key] = true;
3087
+ else delete state.lessonBulkSelection[key];
3088
+ });
3089
+ state.lessonBulkResult = null;
3090
+ app();
3091
+ }));
3092
+ document.querySelectorAll("[data-lesson-bulk-clear]").forEach((button) => button.addEventListener("click", () => {
3093
+ state.lessonBulkSelection = {};
3094
+ state.lessonBulkResult = null;
3095
+ app();
3096
+ }));
3097
+ document.querySelectorAll("[data-lesson-bulk-create]").forEach((button) => button.addEventListener("click", () => createSelectedLessonSedimentationFromDashboard(button)));
2245
3098
  document.querySelectorAll("[data-page-kind]").forEach((button) => button.addEventListener("click", () => {
2246
3099
  const page = Math.max(1, Number(button.dataset.page) || 1);
2247
3100
  if (button.dataset.pageKind === "warning") state.warningPage = page;
@@ -2282,6 +3135,7 @@ function bind() {
2282
3135
  openLessonDrawer(lessonId);
2283
3136
  }));
2284
3137
  document.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
3138
+ document.querySelectorAll("[data-task-complete]").forEach((button) => button.addEventListener("click", () => completeTaskFromDashboard(button.dataset.taskComplete)));
2285
3139
  const overlay = document.getElementById("drawer-overlay");
2286
3140
  if (overlay) overlay.addEventListener("click", closeDrawer);
2287
3141
  }
@@ -2356,6 +3210,90 @@ async function completeReviewFromDashboard(taskId) {
2356
3210
  }
2357
3211
  }
2358
3212
 
3213
+ async function completeTaskFromDashboard(taskId) {
3214
+ const result = document.querySelector(`[data-task-complete-result="${CSS.escape(taskId)}"]`);
3215
+ if (result) result.textContent = t("taskCloseoutSubmitting");
3216
+ try {
3217
+ const response = await fetch("/api/tasks/task-complete", {
3218
+ method: "POST",
3219
+ headers: {
3220
+ "content-type": "application/json",
3221
+ "x-harness-csrf": state.runtime?.csrfToken || "",
3222
+ },
3223
+ body: JSON.stringify({
3224
+ taskId,
3225
+ message: "closed from dashboard workbench",
3226
+ evidence: "dashboard:task-complete",
3227
+ }),
3228
+ });
3229
+ const payload = await response.json();
3230
+ if (!response.ok) throw new Error(payload.error || t("taskCloseoutFailed"));
3231
+ if (result) result.textContent = t("taskCloseoutSuccess");
3232
+ setTimeout(() => window.location.reload(), 500);
3233
+ } catch (error) {
3234
+ if (result) result.textContent = `${t("taskCloseoutFailed")}: ${error.message}`;
3235
+ }
3236
+ }
3237
+
3238
+ function dashboardActionErrorDetail(error, fallback) {
3239
+ const direct = error?.error || error?.message;
3240
+ if (direct) return direct;
3241
+ const failedResults = Array.isArray(error?.results) ? error.results.filter((result) => result?.ok === false) : [];
3242
+ if (failedResults.length > 0) {
3243
+ const reasons = [];
3244
+ for (const result of failedResults) {
3245
+ const reason = result?.error || result?.message;
3246
+ if (reason && !reasons.includes(reason)) reasons.push(reason);
3247
+ }
3248
+ if (reasons.length > 0) {
3249
+ return formatMessage("bulkActionFailedWithReason", {
3250
+ failed: error?.failed || failedResults.length,
3251
+ reason: reasons.slice(0, 3).join("; "),
3252
+ });
3253
+ }
3254
+ return formatMessage("bulkActionFailedSummary", { failed: error?.failed || failedResults.length });
3255
+ }
3256
+ return String(error || fallback);
3257
+ }
3258
+
3259
+ async function confirmSelectedReviewsFromDashboard(button) {
3260
+ const taskIds = reviewBulkSelectedIds();
3261
+ if (!taskIds.length) {
3262
+ state.reviewBulkResult = { ok: false, message: t("reviewBulkNone") };
3263
+ app();
3264
+ return;
3265
+ }
3266
+ button.disabled = true;
3267
+ state.reviewBulkResult = { ok: true, message: t("reviewBulkSubmitting") };
3268
+ app();
3269
+ try {
3270
+ const response = await fetch("/api/tasks/review-complete-bulk", {
3271
+ method: "POST",
3272
+ headers: {
3273
+ "content-type": "application/json",
3274
+ "x-harness-csrf": state.runtime?.csrfToken || "",
3275
+ },
3276
+ body: JSON.stringify({
3277
+ taskIds,
3278
+ reviewer: "Human Reviewer",
3279
+ message: "bulk confirmed from dashboard workbench",
3280
+ }),
3281
+ });
3282
+ const payload = await response.json();
3283
+ if (!response.ok) throw payload;
3284
+ state.reviewBulkSelection = {};
3285
+ state.reviewBulkResult = {
3286
+ ok: payload.failed === 0,
3287
+ message: payload.failed ? formatMessage("reviewBulkPartial", { confirmed: payload.confirmed || 0, failed: payload.failed || 0 }) : formatMessage("reviewBulkSuccess", { confirmed: payload.confirmed || 0 }),
3288
+ };
3289
+ app();
3290
+ if ((payload.confirmed || 0) > 0) setTimeout(() => window.location.reload(), 1500);
3291
+ } catch (error) {
3292
+ state.reviewBulkResult = { ok: false, message: `${t("reviewCompleteFailed")}: ${dashboardActionErrorDetail(error, t("reviewCompleteFailed"))}` };
3293
+ app();
3294
+ }
3295
+ }
3296
+
2359
3297
  async function runPresetAction(action, body) {
2360
3298
  state.presetActionResult = { ok: true, title: t("presetActionRunning"), message: action };
2361
3299
  app();
@@ -2454,6 +3392,7 @@ function openDrawer(taskId) {
2454
3392
  bindRepairPromptButtons(drawer);
2455
3393
  bindLessonSedimentationButtons(drawer);
2456
3394
  drawer.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
3395
+ drawer.querySelectorAll("[data-task-complete]").forEach((button) => button.addEventListener("click", () => completeTaskFromDashboard(button.dataset.taskComplete)));
2457
3396
  }
2458
3397
 
2459
3398
  function bindCopyTaskNameButtons(root) {
@@ -2572,6 +3511,40 @@ async function createLessonSedimentationFromDashboard(button) {
2572
3511
  }
2573
3512
  }
2574
3513
 
3514
+ async function createSelectedLessonSedimentationFromDashboard(button) {
3515
+ const selections = lessonBulkSelectedSelections();
3516
+ if (!selections.length) {
3517
+ state.lessonBulkResult = { ok: false, message: t("lessonBulkNone") };
3518
+ app();
3519
+ return;
3520
+ }
3521
+ button.disabled = true;
3522
+ state.lessonBulkResult = { ok: true, message: t("lessonBulkSubmitting") };
3523
+ app();
3524
+ try {
3525
+ const response = await fetch("/api/tasks/lesson-sedimentation-bulk", {
3526
+ method: "POST",
3527
+ headers: {
3528
+ "content-type": "application/json",
3529
+ "x-harness-csrf": state.runtime?.csrfToken || "",
3530
+ },
3531
+ body: JSON.stringify({ selections }),
3532
+ });
3533
+ const payload = await response.json();
3534
+ if (!response.ok) throw payload;
3535
+ state.lessonBulkSelection = {};
3536
+ state.lessonBulkResult = {
3537
+ ok: payload.failed === 0,
3538
+ message: payload.failed ? formatMessage("lessonBulkPartial", { created: payload.created || 0, failed: payload.failed || 0 }) : formatMessage("lessonBulkSuccess", { candidates: payload.candidates || selections.length }),
3539
+ };
3540
+ app();
3541
+ if ((payload.created || 0) > 0) setTimeout(() => window.location.reload(), 1500);
3542
+ } catch (error) {
3543
+ state.lessonBulkResult = { ok: false, message: `${t("lessonTaskCreateFailed")}: ${error?.error || error?.message || String(error)}` };
3544
+ app();
3545
+ }
3546
+ }
3547
+
2575
3548
  function lessonSedimentationSuccess(payload) {
2576
3549
  const followUp = payload?.followUpTask || {};
2577
3550
  const prompt = payload?.prompt || "";