coding-agent-harness 1.0.1 → 1.0.4
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.
- package/CHANGELOG.md +44 -0
- package/CONTRIBUTING.md +98 -0
- package/README.en-US.md +14 -0
- package/README.md +230 -80
- package/README.zh-CN.md +290 -0
- package/SKILL.md +132 -198
- package/docs-release/README.md +80 -9
- package/docs-release/architecture/overview.md +298 -28
- package/docs-release/architecture/overview.zh-CN.md +292 -0
- package/docs-release/assets/dashboard-overview.png +0 -0
- package/docs-release/assets/harness-architecture.svg +163 -0
- package/docs-release/assets/harness-workflow.svg +64 -0
- package/docs-release/guides/agent-installation.en-US.md +237 -0
- package/docs-release/guides/agent-installation.md +149 -27
- package/docs-release/guides/contributing.md +100 -0
- package/docs-release/guides/contributing.zh-CN.md +99 -0
- package/docs-release/guides/document-audience-and-surfaces.en-US.md +113 -0
- package/docs-release/guides/document-audience-and-surfaces.md +113 -0
- package/docs-release/guides/full-legacy-migration-subagent-strategy.md +334 -0
- package/docs-release/guides/full-legacy-migration-subagent-strategy.zh-CN.md +334 -0
- package/docs-release/guides/legacy-migration-agent-prompt.md +373 -0
- package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +350 -0
- package/docs-release/guides/migration-playbook.en-US.md +324 -0
- package/docs-release/guides/migration-playbook.md +328 -0
- package/docs-release/guides/parent-control-repository-pattern.en-US.md +254 -0
- package/docs-release/guides/parent-control-repository-pattern.md +254 -0
- package/docs-release/guides/preset-development.md +214 -0
- package/docs-release/guides/repository-operating-models.en-US.md +197 -0
- package/docs-release/guides/repository-operating-models.md +197 -0
- package/docs-release/guides/task-state-machine.en-US.md +207 -0
- package/docs-release/guides/task-state-machine.md +214 -0
- package/docs-release/intl/README.md +15 -0
- package/docs-release/intl/de-DE.md +18 -0
- package/docs-release/intl/en-US.md +18 -0
- package/docs-release/intl/es-ES.md +18 -0
- package/docs-release/intl/fr-FR.md +18 -0
- package/docs-release/intl/ja-JP.md +18 -0
- package/docs-release/intl/ko-KR.md +18 -0
- package/docs-release/intl/zh-CN.md +18 -0
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/brief.md +13 -0
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/findings.md +7 -0
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/lesson_candidates.md +24 -0
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/progress.md +1 -1
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/task_plan.md +4 -2
- package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/{visual_roadmap.md → visual_map.md} +9 -1
- package/package.json +10 -3
- package/presets/legacy-migration/checks/preset-check.mjs +3 -0
- package/presets/legacy-migration/preset.yaml +134 -0
- package/presets/legacy-migration/scripts/plan-work-queue.mjs +4 -0
- package/presets/legacy-migration/scripts/scaffold-task-contracts.mjs +4 -0
- package/presets/legacy-migration/templates/execution_strategy.append.md +18 -0
- package/presets/legacy-migration/templates/findings.seed.md +17 -0
- package/presets/legacy-migration/templates/review.seed.md +12 -0
- package/presets/legacy-migration/templates/task_plan.append.md +9 -0
- package/presets/legacy-migration/templates/visual_map.append.md +12 -0
- package/presets/legacy-migration/workbench/dashboard-panels.yaml +2 -0
- package/presets/legacy-migration/workbench/migration-queue.schema.json +23 -0
- package/presets/lesson-sedimentation/preset.yaml +23 -0
- package/presets/lesson-sedimentation/templates/prompt.md +23 -0
- package/presets/module/preset.yaml +25 -0
- package/presets/module/templates/execution_strategy.append.md +8 -0
- package/presets/module/templates/task_plan.append.md +17 -0
- package/presets/standard-task/preset.yaml +31 -0
- package/presets/standard-task/templates/task_plan.append.md +7 -0
- package/references/adversarial-review-standard.md +2 -2
- package/references/agents-md-pattern.md +5 -5
- package/references/delivery-operating-model-standard.md +3 -3
- package/references/docs-directory-standard.md +53 -10
- package/references/external-source-intake-standard.md +75 -0
- package/references/harness-ledger.md +53 -94
- package/references/legacy-12-phase-bootstrap.md +41 -0
- package/references/lessons-governance.md +100 -88
- package/references/module-parallel-standard.md +14 -14
- package/references/planning-loop.md +51 -7
- package/references/project-onboarding-audit.md +10 -0
- package/references/pull-request-standard.md +118 -0
- package/references/repo-governance-standard.md +12 -1
- package/references/review-routing-standard.md +7 -1
- package/references/ssot-governance.md +67 -59
- package/references/taskr-gap-analysis.md +600 -0
- package/references/testing-standard.md +50 -0
- package/references/walkthrough-closeout.md +10 -9
- package/scripts/check-harness.mjs +111 -331
- package/scripts/commands/dashboard-command.mjs +67 -0
- package/scripts/commands/migration-command.mjs +96 -0
- package/scripts/commands/preset-command.mjs +73 -0
- package/scripts/commands/task-command.mjs +327 -0
- package/scripts/harness.mjs +106 -20
- package/scripts/lib/capability-registry.mjs +591 -0
- package/scripts/lib/check-module-parallel.mjs +237 -0
- package/scripts/lib/check-profiles.mjs +418 -0
- package/scripts/lib/check-task-contracts.mjs +47 -0
- package/scripts/lib/core-shared.mjs +196 -0
- package/scripts/lib/dashboard-data.mjs +412 -0
- package/scripts/lib/dashboard-workbench.mjs +257 -0
- package/scripts/lib/dashboard-writer.mjs +107 -4
- package/scripts/lib/git-status-summary.mjs +46 -0
- package/scripts/lib/governance-index-generator.mjs +174 -0
- package/scripts/lib/governance-sync.mjs +514 -0
- package/scripts/lib/governance-table-boundary.mjs +175 -0
- package/scripts/lib/harness-core.mjs +15 -1318
- package/scripts/lib/lesson-maintenance.mjs +152 -0
- package/scripts/lib/markdown-utils.mjs +158 -0
- package/scripts/lib/migration-planner.mjs +478 -0
- package/scripts/lib/migration-support.mjs +312 -0
- package/scripts/lib/preset-audit-contracts.mjs +37 -0
- package/scripts/lib/preset-engine.mjs +497 -0
- package/scripts/lib/preset-registry.mjs +627 -0
- package/scripts/lib/preset-resource-contracts.mjs +83 -0
- package/scripts/lib/review-confirm-git-gate.mjs +248 -0
- package/scripts/lib/status-dashboard-renderer.mjs +102 -0
- package/scripts/lib/subagent-authorization-audit.mjs +196 -0
- package/scripts/lib/task-completion-consistency.mjs +16 -0
- package/scripts/lib/task-index.mjs +93 -0
- package/scripts/lib/task-lesson-candidates.mjs +242 -0
- package/scripts/lib/task-lesson-sedimentation.mjs +326 -0
- package/scripts/lib/task-lifecycle/review-confirm.mjs +101 -0
- package/scripts/lib/task-lifecycle/review-gates.mjs +70 -0
- package/scripts/lib/task-lifecycle/text-utils.mjs +24 -0
- package/scripts/lib/task-lifecycle.mjs +649 -0
- package/scripts/lib/task-review-model.mjs +469 -0
- package/scripts/lib/task-scanner.mjs +576 -0
- package/scripts/lib/task-tombstone-commands.mjs +140 -0
- package/scripts/postinstall.mjs +14 -0
- package/skills/preset-creator/SKILL.md +179 -0
- package/skills/preset-creator/references/complex-task-skeleton/README.md +31 -0
- package/skills/preset-creator/references/complex-task-skeleton/artifacts/INDEX.md +12 -0
- package/skills/preset-creator/references/complex-task-skeleton/brief.md +32 -0
- package/skills/preset-creator/references/complex-task-skeleton/execution_strategy.md +71 -0
- package/skills/preset-creator/references/complex-task-skeleton/findings.md +24 -0
- package/skills/preset-creator/references/complex-task-skeleton/lesson_candidates.md +70 -0
- package/skills/preset-creator/references/complex-task-skeleton/long-running-task-contract.md +76 -0
- package/skills/preset-creator/references/complex-task-skeleton/progress.md +33 -0
- package/skills/preset-creator/references/complex-task-skeleton/references/INDEX.md +13 -0
- package/skills/preset-creator/references/complex-task-skeleton/review.md +107 -0
- package/skills/preset-creator/references/complex-task-skeleton/task_plan.md +111 -0
- package/{templates/planning/visual_roadmap.md → skills/preset-creator/references/complex-task-skeleton/visual_map.md} +24 -2
- package/skills/preset-creator/references/preset-package-skeleton.md +296 -0
- package/templates/AGENTS.md.template +51 -36
- package/templates/architecture/Architecture-SSoT.md +21 -0
- package/templates/architecture/README.md +49 -0
- package/templates/architecture/critical-flows.md +22 -0
- package/templates/architecture/local-repo-context.md +20 -0
- package/templates/architecture/service-catalog.md +17 -0
- package/templates/architecture/services/service-template.md +31 -0
- package/templates/architecture/system-map.md +22 -0
- package/templates/dashboard/assets/app-src/00-state.js +42 -0
- package/templates/dashboard/assets/app-src/10-router.js +77 -0
- package/templates/dashboard/assets/app-src/20-overview.js +241 -0
- package/templates/dashboard/assets/app-src/30-tasks.js +409 -0
- package/templates/dashboard/assets/app-src/35-task-detail.js +246 -0
- package/templates/dashboard/assets/app-src/40-modules.js +58 -0
- package/templates/dashboard/assets/app-src/45-review.js +347 -0
- package/templates/dashboard/assets/app-src/50-migration.js +183 -0
- package/templates/dashboard/assets/app-src/60-shared.js +61 -0
- package/templates/dashboard/assets/app-src/90-bindings.js +524 -0
- package/templates/dashboard/assets/app.css +3107 -300
- package/templates/dashboard/assets/app.css.manifest.json +9 -0
- package/templates/dashboard/assets/app.js +2068 -306
- package/templates/dashboard/assets/app.manifest.json +12 -0
- package/templates/dashboard/assets/css-src/00-foundation.css +342 -0
- package/templates/dashboard/assets/css-src/10-panels-flow.css +236 -0
- package/templates/dashboard/assets/css-src/20-briefs-controls.css +398 -0
- package/templates/dashboard/assets/css-src/30-task-index.css +739 -0
- package/templates/dashboard/assets/css-src/35-review-workspace.css +507 -0
- package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +427 -0
- package/templates/dashboard/assets/css-src/50-responsive-overrides.css +551 -0
- package/templates/dashboard/assets/i18n.js +531 -44
- package/templates/dashboard/assets/mermaid-renderer.js +58 -8
- package/templates/development/README.md +52 -0
- package/templates/development/codebase-map.md +11 -0
- package/templates/development/cross-repo-debugging.md +18 -0
- package/templates/development/external-context/service-template.md +33 -0
- package/templates/development/external-source-packs/README.md +24 -0
- package/templates/development/external-source-packs/digest-template.md +28 -0
- package/templates/development/local-setup.md +16 -0
- package/templates/development/stubs-and-mocks.md +11 -0
- package/templates/integrations/README.md +40 -0
- package/templates/integrations/api-contract.md +42 -0
- package/templates/integrations/event-contract.md +46 -0
- package/templates/integrations/third-party/vendor-template.md +42 -0
- package/templates/integrations/webhook-contract.md +41 -0
- package/templates/ledger/Harness-Ledger.md +13 -25
- package/templates/lessons/lesson-arch-process-change.md +1 -1
- package/templates/lessons/lesson-new-doc.md +1 -1
- package/templates/lessons/lesson-ref-change.md +1 -1
- package/templates/planning/brief.md +32 -0
- package/templates/planning/execution_strategy.md +31 -0
- package/templates/planning/lesson_candidates.md +70 -0
- package/templates/planning/long-running-task-contract.md +7 -0
- package/templates/planning/module_brief.md +25 -0
- package/templates/planning/module_session_prompt.md +6 -0
- package/templates/planning/optional/artifacts/INDEX.md +3 -3
- package/templates/planning/optional/references/INDEX.md +3 -3
- package/templates/planning/review.md +59 -0
- package/templates/planning/task_plan.md +40 -15
- package/templates/planning/visual_map.md +50 -0
- package/templates/reference/docs-library-standard.md +31 -0
- package/templates/reference/execution-workflow-standard.md +5 -2
- package/templates/reference/external-source-intake-standard.md +82 -0
- package/templates/reference/harness-ledger-standard.md +1 -0
- package/templates/reference/pull-request-standard.md +80 -0
- package/templates/reference/repo-governance-standard.md +8 -5
- package/templates/reference/review-routing-standard.md +6 -0
- package/templates/reference/walkthrough-standard.md +3 -1
- package/templates/verifier/verifier-output.md +1 -1
- package/templates/walkthrough/walkthrough-template.md +2 -2
- package/templates-zh-CN/AGENTS.md.template +73 -70
- package/templates-zh-CN/architecture/Architecture-SSoT.md +21 -0
- package/templates-zh-CN/architecture/README.md +51 -0
- package/templates-zh-CN/architecture/critical-flows.md +24 -0
- package/templates-zh-CN/architecture/local-repo-context.md +20 -0
- package/templates-zh-CN/architecture/service-catalog.md +17 -0
- package/templates-zh-CN/architecture/services/service-template.md +31 -0
- package/templates-zh-CN/architecture/system-map.md +22 -0
- package/templates-zh-CN/development/README.md +54 -0
- package/templates-zh-CN/development/codebase-map.md +11 -0
- package/templates-zh-CN/development/cross-repo-debugging.md +18 -0
- package/templates-zh-CN/development/external-context/service-template.md +33 -0
- package/templates-zh-CN/development/external-source-packs/README.md +24 -0
- package/templates-zh-CN/development/external-source-packs/digest-template.md +28 -0
- package/templates-zh-CN/development/local-setup.md +16 -0
- package/templates-zh-CN/development/stubs-and-mocks.md +11 -0
- package/templates-zh-CN/integrations/README.md +42 -0
- package/templates-zh-CN/integrations/api-contract.md +42 -0
- package/templates-zh-CN/integrations/event-contract.md +46 -0
- package/templates-zh-CN/integrations/third-party/vendor-template.md +42 -0
- package/templates-zh-CN/integrations/webhook-contract.md +41 -0
- package/templates-zh-CN/ledger/Harness-Ledger.md +17 -40
- package/templates-zh-CN/planning/brief.md +32 -0
- package/templates-zh-CN/planning/execution_strategy.md +30 -0
- package/templates-zh-CN/planning/lesson_candidates.md +70 -0
- package/templates-zh-CN/planning/long-running-task-contract.md +1 -1
- package/templates-zh-CN/planning/module_brief.md +25 -0
- package/templates-zh-CN/planning/module_plan.md +2 -2
- package/templates-zh-CN/planning/module_session_prompt.md +4 -3
- package/templates-zh-CN/planning/review.md +59 -1
- package/templates-zh-CN/planning/task_plan.md +37 -11
- package/templates-zh-CN/planning/{visual_roadmap.md → visual_map.md} +21 -2
- package/templates-zh-CN/reference/adversarial-review-standard.md +1 -1
- package/templates-zh-CN/reference/docs-library-standard.md +36 -1
- package/templates-zh-CN/reference/execution-workflow-standard.md +10 -2
- package/templates-zh-CN/reference/external-source-intake-standard.md +82 -0
- package/templates-zh-CN/reference/harness-ledger-standard.md +7 -4
- package/templates-zh-CN/reference/pull-request-standard.md +106 -0
- package/templates-zh-CN/reference/repo-governance-standard.md +4 -1
- package/templates-zh-CN/reference/review-routing-standard.md +8 -1
- package/templates-zh-CN/reference/walkthrough-standard.md +6 -5
- package/templates-zh-CN/walkthrough/Closeout-SSoT.md +2 -2
- package/templates-zh-CN/walkthrough/walkthrough-template.md +2 -2
- package/scripts/smoke-dashboard.mjs +0 -70
- package/scripts/test-harness.mjs +0 -483
- package/templates/ssot/Feature-SSoT.md +0 -43
- package/templates/ssot/Lessons-SSoT.md +0 -44
- package/templates-zh-CN/dashboard/assets/app.css +0 -399
- package/templates-zh-CN/dashboard/assets/app.js +0 -435
- package/templates-zh-CN/dashboard/assets/i18n.js +0 -47
- package/templates-zh-CN/dashboard/assets/markdown-reader.js +0 -116
- package/templates-zh-CN/dashboard/assets/mermaid-renderer.js +0 -59
- package/templates-zh-CN/dashboard/index.html +0 -18
- package/templates-zh-CN/ssot/Feature-SSoT.md +0 -49
- package/templates-zh-CN/ssot/Lessons-SSoT.md +0 -49
|
@@ -1,383 +1,1661 @@
|
|
|
1
1
|
const bundle = window.__HARNESS_DASHBOARD__ || {};
|
|
2
|
+
const defaultLocale = window.__HARNESS_LOCALE__ || ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en");
|
|
3
|
+
let locale = localStorage.getItem("harness.locale") || defaultLocale;
|
|
4
|
+
if (!window.HarnessI18n?.[locale]) locale = "en";
|
|
5
|
+
let labels = window.HarnessI18n?.[locale] || {};
|
|
6
|
+
|
|
2
7
|
const state = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
query: "",
|
|
9
|
+
taskState: "all",
|
|
10
|
+
taskGroupMode: "migration",
|
|
11
|
+
taskPageByGroup: {},
|
|
12
|
+
taskGroupPage: 1,
|
|
13
|
+
warningFilter: "all",
|
|
14
|
+
warningPage: 1,
|
|
9
15
|
renderMode: "rendered",
|
|
16
|
+
theme: localStorage.getItem("harness.theme") || "system",
|
|
17
|
+
taskLayout: localStorage.getItem("harness.taskLayout") || "list",
|
|
18
|
+
taskSortOrder: localStorage.getItem("harness.taskSortOrder") === "asc" ? "asc" : "desc",
|
|
19
|
+
runtime: { mode: "static", csrfToken: "", writableActions: [] },
|
|
20
|
+
runtimeLoaded: false,
|
|
21
|
+
runtimePoller: null,
|
|
10
22
|
};
|
|
11
23
|
|
|
12
|
-
const
|
|
24
|
+
const taskPageSize = 25;
|
|
25
|
+
const taskGroupsPerPage = 8;
|
|
26
|
+
const warningPageSize = 18;
|
|
27
|
+
|
|
13
28
|
const taskDocTabs = [
|
|
14
|
-
["
|
|
29
|
+
["brief", "brief.md"],
|
|
30
|
+
["taskPlan", "task_plan.md"],
|
|
15
31
|
["strategy", "execution_strategy.md"],
|
|
16
|
-
["
|
|
32
|
+
["visualMap", "visual_map.md"],
|
|
33
|
+
["legacyRoadmap", "visual_roadmap.md"],
|
|
34
|
+
["lessonCandidates", "lesson_candidates.md"],
|
|
35
|
+
["longRunningContract", "long-running-task-contract.md"],
|
|
17
36
|
["progress", "progress.md"],
|
|
18
37
|
["review", "review.md"],
|
|
19
38
|
["findings", "findings.md"],
|
|
39
|
+
["walkthrough", "__walkthrough__"],
|
|
20
40
|
["references", "references/INDEX.md"],
|
|
21
41
|
["artifacts", "artifacts/INDEX.md"],
|
|
22
42
|
];
|
|
23
43
|
|
|
24
44
|
function t(key) {
|
|
25
|
-
return
|
|
45
|
+
return labels[key] || key;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setLocale(nextLocale) {
|
|
49
|
+
locale = window.HarnessI18n?.[nextLocale] ? nextLocale : "en";
|
|
50
|
+
labels = window.HarnessI18n?.[locale] || {};
|
|
51
|
+
localStorage.setItem("harness.locale", locale);
|
|
26
52
|
}
|
|
27
53
|
|
|
28
54
|
function app() {
|
|
29
55
|
const systemTheme = window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
30
56
|
document.documentElement.dataset.theme = state.theme === "system" ? systemTheme : state.theme;
|
|
31
|
-
document.documentElement.
|
|
32
|
-
document.documentElement.lang = "en";
|
|
57
|
+
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
|
33
58
|
const root = document.getElementById("app");
|
|
34
|
-
root.innerHTML =
|
|
35
|
-
<div class="layout">
|
|
36
|
-
<aside class="sidebar">
|
|
37
|
-
<div class="brand">
|
|
38
|
-
<strong>${escapeHtml(bundle.status?.project?.name || "Harness")}</strong>
|
|
39
|
-
<span>${escapeHtml(bundle.status?.project?.root || "TARGET:.")}</span>
|
|
40
|
-
</div>
|
|
41
|
-
<nav class="nav">${pageKeys.map((page) => navButton(page)).join("")}</nav>
|
|
42
|
-
</aside>
|
|
43
|
-
<main class="main">
|
|
44
|
-
${topbar()}
|
|
45
|
-
${renderPage()}
|
|
46
|
-
</main>
|
|
47
|
-
</div>`;
|
|
59
|
+
root.innerHTML = shell();
|
|
48
60
|
bind();
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
function
|
|
52
|
-
return `<
|
|
63
|
+
function shell() {
|
|
64
|
+
return `<div class="visibility-shell">
|
|
65
|
+
<header class="hero">
|
|
66
|
+
<div class="hero-copy">
|
|
67
|
+
<p class="eyebrow">${t("eyebrow")}</p>
|
|
68
|
+
<h1>${escapeHtml(projectName())} ${t("projectCockpit")}</h1>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="hero-actions">
|
|
71
|
+
${routeLink("#/", t("overview"), "overview")}
|
|
72
|
+
${routeLink("#/tasks", t("taskIndex"), "tasks")}
|
|
73
|
+
${routeLink("#/review", t("reviewQueue"), "review")}
|
|
74
|
+
${routeLink("#/modules", t("moduleView"), "modules")}
|
|
75
|
+
<button data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
|
|
76
|
+
<button data-theme-toggle>${themeLabel()}</button>
|
|
77
|
+
</div>
|
|
78
|
+
</header>
|
|
79
|
+
${runtimeModeBanner()}
|
|
80
|
+
${renderRoute()}
|
|
81
|
+
<div id="drawer-overlay" class="drawer-overlay"></div>
|
|
82
|
+
<div id="task-drawer" class="task-drawer"></div>
|
|
83
|
+
</div>`;
|
|
53
84
|
}
|
|
54
85
|
|
|
55
|
-
function
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
function runtimeModeBanner() {
|
|
87
|
+
if (window.__HARNESS_WORKBENCH__ === true) return "";
|
|
88
|
+
return `<section class="runtime-banner">
|
|
89
|
+
<strong>${t("staticReadOnly")}</strong>
|
|
90
|
+
<span>${t("staticReadOnlyDetail")}</span>
|
|
91
|
+
<code>harness dev</code>
|
|
92
|
+
</section>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderRoute() {
|
|
96
|
+
const route = currentRoute();
|
|
97
|
+
if (route.name === "task") return taskDetail(route);
|
|
98
|
+
if (route.name === "reviewTask") return reviewWorkspace(route);
|
|
99
|
+
if (route.name === "review") return reviewQueue();
|
|
100
|
+
if (route.name === "modules") return modulesView(route.id);
|
|
101
|
+
if (route.name === "tasks") return taskIndex();
|
|
102
|
+
return overview();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function currentRoute() {
|
|
106
|
+
const hash = window.location.hash || "#/";
|
|
107
|
+
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(decodeURIComponent);
|
|
108
|
+
if (parts[0] === "tasks" && parts[1]) return { name: "task", id: parts[1], doc: parts[2] === "docs" ? parts[3] || "" : "" };
|
|
109
|
+
if (parts[0] === "review" && parts[1]) return { name: "reviewTask", id: parts[1] };
|
|
110
|
+
if (parts[0] === "review") return { name: "review" };
|
|
111
|
+
if (parts[0] === "modules") return { name: "modules", id: parts[1] || "" };
|
|
112
|
+
if (parts[0] === "tasks") return { name: "tasks" };
|
|
113
|
+
return { name: "overview" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function routeLink(hash, text, routeName) {
|
|
117
|
+
const current = currentRoute().name;
|
|
118
|
+
const active = current === routeName || (routeName === "review" && current === "reviewTask");
|
|
119
|
+
return `<a class="${active ? "active" : ""}" href="${hash}">${escapeHtml(text)}</a>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function overview() {
|
|
123
|
+
return `<div class="dashboard-grid">
|
|
124
|
+
<main class="dashboard-main stack">
|
|
125
|
+
${flowPanel()}
|
|
126
|
+
${activeTaskBriefs()}
|
|
127
|
+
${migrationSummaryPanel()}
|
|
128
|
+
</main>
|
|
129
|
+
<aside class="dashboard-sidebar stack">
|
|
130
|
+
${statusStrip()}
|
|
131
|
+
${ledgerPanel()}
|
|
132
|
+
${healthPanel()}
|
|
133
|
+
${lessonPanel()}
|
|
134
|
+
</aside>
|
|
135
|
+
</div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function statusStrip() {
|
|
139
|
+
const status = bundle.status?.checkState?.status || "unknown";
|
|
140
|
+
const failures = bundle.status?.checkState?.failures || 0;
|
|
141
|
+
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
142
|
+
const tasks = bundle.status?.tasks || [];
|
|
143
|
+
const summary = bundle.status?.summary || {};
|
|
144
|
+
const visual = summary.visualMapCoverage || {};
|
|
145
|
+
const withBrief = tasks.filter((task) => task.briefSource === "standalone").length;
|
|
146
|
+
return `<section class="status-card-group">
|
|
147
|
+
<div class="status-primary ${status}">
|
|
148
|
+
<span>${t("readiness")}</span>
|
|
149
|
+
<strong>${label(status)}</strong>
|
|
150
|
+
<p>${nextActionText()}</p>
|
|
60
151
|
</div>
|
|
61
|
-
<div class="
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
152
|
+
<div class="metrics-grid">
|
|
153
|
+
${metric(t("tasks"), tasks.length)}
|
|
154
|
+
${metric(t("briefCoverage"), `${withBrief}/${tasks.length}`)}
|
|
155
|
+
${metric(t("visualMapCoverage"), `${visual.canonical || 0}/${summary.visualMapRequiredCount || tasks.length}`)}
|
|
156
|
+
${metric(t("fullCutover"), summary.fullCutoverEligible ? t("ready") : t("notReady"))}
|
|
157
|
+
${metric(t("legacyVisualOnly"), summary.legacyVisualOnlyCount || 0)}
|
|
158
|
+
${metric(t("weakBrief"), summary.weakBriefCount || 0)}
|
|
159
|
+
${metric(t("blockers"), failures)}
|
|
160
|
+
${metric(t("advice"), warnings)}
|
|
161
|
+
</div>
|
|
162
|
+
</section>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function metric(labelText, value) {
|
|
166
|
+
return `<div class="metric"><span>${escapeHtml(labelText)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nextActionText() {
|
|
170
|
+
const failures = bundle.status?.checkState?.failures || 0;
|
|
171
|
+
if (failures > 0) return t("resolveBlockers");
|
|
172
|
+
const missingBriefs = (bundle.status?.tasks || []).filter((task) => task.briefSource !== "standalone").length;
|
|
173
|
+
if (missingBriefs > 0) return `${missingBriefs} ${t("missingBriefs")}`;
|
|
174
|
+
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
175
|
+
if (warnings > 0) return t("reviewAdvice");
|
|
176
|
+
return t("noBlockers");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function flowPanel() {
|
|
180
|
+
const tasks = bundle.status?.tasks || [];
|
|
181
|
+
const total = tasks.length;
|
|
182
|
+
if (total === 0) return "";
|
|
183
|
+
const active = tasks.filter((task) => isActiveTaskState(task.state)).length;
|
|
184
|
+
const done = tasks.filter((task) => !isActiveTaskState(task.state) && (task.state === "done" || task.completion === 100)).length;
|
|
185
|
+
const planned = Math.max(0, total - done - active);
|
|
186
|
+
const pct = (n) => total > 0 ? Math.round((n / total) * 100) : 0;
|
|
187
|
+
return `<section class="flow-panel">
|
|
188
|
+
<div class="section-head">
|
|
189
|
+
<div>
|
|
190
|
+
<p class="eyebrow">${t("firstLook")}</p>
|
|
191
|
+
<h2>${t("projectProgress")}</h2>
|
|
66
192
|
</div>
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
193
|
+
<span class="subtle">${done}/${total} ${t("completed")}</span>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="progress-bar-container">
|
|
196
|
+
<div class="progress-bar">
|
|
197
|
+
${done > 0 ? `<div class="progress-segment done" style="width:${pct(done)}%" title="${t("done")}: ${done}"></div>` : ""}
|
|
198
|
+
${active > 0 ? `<div class="progress-segment active" style="width:${pct(active)}%" title="${t("active")}: ${active}"></div>` : ""}
|
|
199
|
+
${planned > 0 ? `<div class="progress-segment planned" style="width:${pct(planned)}%" title="${t("planned")}: ${planned}"></div>` : ""}
|
|
200
|
+
</div>
|
|
201
|
+
<div class="progress-legend">
|
|
202
|
+
<span class="legend-item"><span class="legend-dot done"></span>${t("done")} ${done}</span>
|
|
203
|
+
<span class="legend-item"><span class="legend-dot active"></span>${t("active")} ${active}</span>
|
|
204
|
+
<span class="legend-item"><span class="legend-dot planned"></span>${t("planned")} ${planned}</span>
|
|
70
205
|
</div>
|
|
71
206
|
</div>
|
|
207
|
+
${usesAggregateFlow() ? migrationRunwayBreakdown() : ""}
|
|
208
|
+
</section>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function projectMermaid() {
|
|
212
|
+
if (usesAggregateFlow()) return migrationAggregateMermaid();
|
|
213
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
214
|
+
const preferredTypes = graph.nodes?.some((node) => node.type === "module") ? ["module", "step"] : ["task", "phase"];
|
|
215
|
+
const nodes = (graph.nodes || [])
|
|
216
|
+
.filter((node) => preferredTypes.includes(node.type))
|
|
217
|
+
.filter((node) => node.type !== "phase" || ["in_progress", "review", "blocked", "done"].includes(node.state))
|
|
218
|
+
.slice(0, 28);
|
|
219
|
+
if (nodes.length < 2) return mermaidFromBriefs();
|
|
220
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
221
|
+
const lines = ["flowchart LR"];
|
|
222
|
+
let edgeCount = 0;
|
|
223
|
+
for (const edge of graph.edges || []) {
|
|
224
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) continue;
|
|
225
|
+
lines.push(` ${mermaidId(edge.from)}["${mermaidLabel(edge.from)}"] --> ${mermaidId(edge.to)}["${mermaidLabel(edge.to)}"]`);
|
|
226
|
+
edgeCount += 1;
|
|
227
|
+
if (edgeCount >= 34) break;
|
|
228
|
+
}
|
|
229
|
+
if (edgeCount === 0) {
|
|
230
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
231
|
+
lines.push(` ${mermaidId(nodes[index - 1].id)}["${mermaidLabel(nodes[index - 1].id)}"] --> ${mermaidId(nodes[index].id)}["${mermaidLabel(nodes[index].id)}"]`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return lines.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function usesAggregateFlow() {
|
|
238
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
239
|
+
const taskCount = (bundle.status?.tasks || []).length;
|
|
240
|
+
const taskNodes = (graph.nodes || []).filter((node) => node.type === "task").length;
|
|
241
|
+
const usefulEdges = (graph.edges || []).filter((edge) => ["depends_on", "current_step"].includes(edge.type)).length;
|
|
242
|
+
return taskCount > 80 || taskNodes > 80 || ((graph.nodes || []).length > 80 && usefulEdges < 6);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function migrationAggregateMermaid() {
|
|
246
|
+
const tasks = bundle.status?.tasks || [];
|
|
247
|
+
const warnings = warningQueue();
|
|
248
|
+
const activeContracts = warnings.filter((warning) => warning.phase === "active-task-contracts").length;
|
|
249
|
+
const moduleCount = new Set(tasks.map(taskModuleKey)).size;
|
|
250
|
+
const reviewWarnings = warnings.filter((warning) => ["review-evidence", "strict-cutover"].includes(warning.phase)).length;
|
|
251
|
+
const lines = [
|
|
252
|
+
"flowchart LR",
|
|
253
|
+
` baseline["${t("runwayBaseline")}\\n${tasks.length} ${t("tasks")}"] --> triage["${t("runwayTriage")}\\n${warnings.length} ${t("warnings")}"]`,
|
|
254
|
+
` triage --> contracts["${t("runwayContracts")}\\n${activeContracts} ${t("items")}"]`,
|
|
255
|
+
` contracts --> modules["${t("runwayModules")}\\n${moduleCount} ${t("groups")}"]`,
|
|
256
|
+
` modules --> cutover["${t("runwayCutover")}\\n${reviewWarnings} ${t("items")}"]`,
|
|
257
|
+
];
|
|
258
|
+
return lines.join("\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function migrationRunwayBreakdown() {
|
|
262
|
+
const tasks = bundle.status?.tasks || [];
|
|
263
|
+
const warnings = warningQueue();
|
|
264
|
+
const phases = [
|
|
265
|
+
["baseline", t("runwayBaseline"), tasks.length, t("tasks"), "#/tasks"],
|
|
266
|
+
["triage", t("runwayTriage"), warnings.length, t("warnings"), "#/"],
|
|
267
|
+
["active-task-contracts", t("runwayContracts"), warnings.filter((warning) => warning.phase === "active-task-contracts").length, t("items"), "#/"],
|
|
268
|
+
["module-classification", t("runwayModules"), new Set(tasks.map(taskModuleKey)).size, t("groups"), "#/tasks"],
|
|
269
|
+
["strict-cutover", t("runwayCutover"), warnings.filter((warning) => warning.phase === "strict-cutover").length, t("items"), "#/"],
|
|
270
|
+
];
|
|
271
|
+
return `<div class="runway-breakdown">
|
|
272
|
+
${phases.map(([phase, title, count, unit, href]) => `<a href="${href}" data-runway-phase="${escapeAttr(phase)}"><strong>${escapeHtml(title)}</strong><span>${count} ${escapeHtml(unit)}</span></a>`).join("")}
|
|
72
273
|
</div>`;
|
|
73
274
|
}
|
|
74
275
|
|
|
75
|
-
function
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
return
|
|
276
|
+
function mermaidFromBriefs() {
|
|
277
|
+
const brief = activeTasks().map((task) => taskDocument(task, "brief.md")).find((doc) => doc?.content?.includes("```mermaid"));
|
|
278
|
+
const match = brief?.content.match(/```mermaid\s*([\s\S]*?)```/i);
|
|
279
|
+
return match ? match[1].trim() : "";
|
|
79
280
|
}
|
|
80
281
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
if (state.page === "modules") return withDrawer(moduleTable());
|
|
86
|
-
if (state.page === "evidence") return withDrawer(evidenceTable());
|
|
87
|
-
if (state.page === "lessons") return withDrawer(lessonsTable());
|
|
88
|
-
if (state.page === "adoption") return adoption();
|
|
89
|
-
return settings();
|
|
282
|
+
function graphSummary() {
|
|
283
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
284
|
+
if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${(bundle.status?.tasks || []).length} ${t("tasks")}`;
|
|
285
|
+
return `${graph.nodes?.length || 0} ${t("nodes")} · ${graph.edges?.length || 0} ${t("edges")}`;
|
|
90
286
|
}
|
|
91
287
|
|
|
92
|
-
function
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<section class="page-grid">
|
|
108
|
-
<div>
|
|
109
|
-
${overviewTasks()}
|
|
110
|
-
${ledgerSummary()}
|
|
111
|
-
${riskPanel()}
|
|
288
|
+
function activeTaskBriefs() {
|
|
289
|
+
const tasks = activeTasks();
|
|
290
|
+
return `<section class="task-briefs">
|
|
291
|
+
<div class="section-head">
|
|
292
|
+
<div>
|
|
293
|
+
<p class="eyebrow">${t("currentWork")}</p>
|
|
294
|
+
<h2>${t("activeBriefs")}</h2>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="section-actions">
|
|
297
|
+
<span class="subtle">${t("activeBriefCount").replace("{count}", tasks.length).replace("{order}", taskSortLabel())}</span>
|
|
298
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="brief-scroll">
|
|
302
|
+
<div class="brief-grid">${tasks.map((task) => taskBriefCard(task, { compact: false })).join("") || emptyState(t("noActiveTasks"))}</div>
|
|
112
303
|
</div>
|
|
113
|
-
${drawer()}
|
|
114
304
|
</section>`;
|
|
115
305
|
}
|
|
116
306
|
|
|
117
|
-
function
|
|
118
|
-
|
|
307
|
+
function activeTasks() {
|
|
308
|
+
const tasks = bundle.status?.tasks || [];
|
|
309
|
+
const active = tasks.filter((task) => isActiveTaskState(task.state) || ["planned", "not_started"].includes(task.state));
|
|
310
|
+
if (active.length > 0) return sortTasksByTime(active);
|
|
311
|
+
return sortTasksByTime(tasks.filter((task) => task.briefSource === "standalone"));
|
|
119
312
|
}
|
|
120
313
|
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
314
|
+
function isActiveTaskState(state) {
|
|
315
|
+
return ["active", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(state);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function taskBriefCard(task, { compact = true } = {}) {
|
|
319
|
+
const doc = taskDocument(task, "brief.md");
|
|
320
|
+
const summaryText = doc ? getBriefSummary(doc.content) : t("missingBriefExplain");
|
|
321
|
+
return `<article class="brief-card ${compact ? "compact" : ""}">
|
|
322
|
+
<div class="card-head">
|
|
323
|
+
<div>
|
|
324
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}">${escapeHtml(task.title)}</a>
|
|
325
|
+
<p>${escapeHtml(task.id)}</p>
|
|
326
|
+
</div>
|
|
327
|
+
${tag(task.state)}
|
|
328
|
+
</div>
|
|
329
|
+
${progressBar(task.completion)}
|
|
330
|
+
<div class="brief-content">
|
|
331
|
+
<p class="brief-teaser">${escapeHtml(summaryText)}</p>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="card-actions">
|
|
334
|
+
${taskCopyButton(task)}
|
|
335
|
+
<button class="btn-drawer-trigger" data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
|
|
336
|
+
</div>
|
|
337
|
+
</article>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getBriefSummary(content) {
|
|
341
|
+
if (!content) return "";
|
|
342
|
+
let text = content
|
|
343
|
+
.replace(/#+\s+/g, "")
|
|
344
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
345
|
+
.replace(/[*_`]/g, "")
|
|
346
|
+
.replace(/-\s+/g, "")
|
|
347
|
+
.replace(/>\s+/g, "")
|
|
348
|
+
.replaceAll("\n", " ")
|
|
349
|
+
.replace(/\s+/g, " ")
|
|
350
|
+
.trim();
|
|
351
|
+
if (text.length > 140) text = text.slice(0, 137) + "...";
|
|
352
|
+
return text;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function generatedBrief(task) {
|
|
356
|
+
const phaseText = (task.phases || []).slice(0, 6).map((phase) => `<li><strong>${escapeHtml(phase.id)}</strong> ${escapeHtml(phase.output || phase.state)} · ${phase.completion}%</li>`).join("");
|
|
357
|
+
return `<div class="missing-brief">
|
|
358
|
+
<strong>${t("visibilityBriefMissing")}</strong>
|
|
359
|
+
<p>${t("missingBriefExplain")}</p>
|
|
360
|
+
<ul>${phaseText || `<li>${t("noPhaseData")}</li>`}</ul>
|
|
361
|
+
</div>`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function clampCompletion(value) {
|
|
365
|
+
const number = Number(value) || 0;
|
|
366
|
+
return Math.max(0, Math.min(100, Math.round(number)));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function stateToColorVar(state) {
|
|
370
|
+
const map = { in_progress: "--accent", review: "--accent-2", blocked: "--danger", done: "--ok", planned: "--muted", not_started: "--muted" };
|
|
371
|
+
return map[state] || "--muted";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function taskSortLabel() {
|
|
375
|
+
return state.taskSortOrder === "asc" ? t("sortOldest") : t("sortNewest");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function taskDateKey(task) {
|
|
379
|
+
const source = `${task.shortId || ""} ${task.id || ""}`.trim();
|
|
380
|
+
const match = source.match(/(?:^|[^\d])(\d{4})-(\d{2})(?:-(\d{2}))?/);
|
|
381
|
+
if (!match) return null;
|
|
382
|
+
const year = Number(match[1]);
|
|
383
|
+
const month = Number(match[2]);
|
|
384
|
+
const day = Number(match[3] || "1");
|
|
385
|
+
if (!year || month < 1 || month > 12 || day < 1 || day > 31) return null;
|
|
386
|
+
return Date.UTC(year, month - 1, day);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function stableTaskLabel(task) {
|
|
390
|
+
return `${task.shortId || ""} ${task.id || ""} ${task.title || ""}`.trim();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function compareTasksByTime(left, right) {
|
|
394
|
+
const leftDate = taskDateKey(left);
|
|
395
|
+
const rightDate = taskDateKey(right);
|
|
396
|
+
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
|
|
397
|
+
return state.taskSortOrder === "asc" ? leftDate - rightDate : rightDate - leftDate;
|
|
398
|
+
}
|
|
399
|
+
if (leftDate !== null && rightDate === null) return -1;
|
|
400
|
+
if (leftDate === null && rightDate !== null) return 1;
|
|
401
|
+
return stableTaskLabel(left).localeCompare(stableTaskLabel(right));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sortTasksByTime(tasks) {
|
|
405
|
+
return [...tasks].sort(compareTasksByTime);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function taskFolderName(task) {
|
|
409
|
+
const fromPath = String(task?.path || "").split("/").filter(Boolean).pop();
|
|
410
|
+
const fromId = String(task?.id || "").split("/").filter(Boolean).pop();
|
|
411
|
+
return task?.shortId || fromPath || fromId || task?.title || "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function taskCopyButton(task, extraClass = "") {
|
|
415
|
+
const folderName = taskFolderName(task);
|
|
416
|
+
return `<button type="button" class="copy-task-name ${extraClass}" data-copy-task-name="${escapeAttr(folderName)}" data-copy-task-folder="${escapeAttr(folderName)}" aria-label="${escapeAttr(t("copyTaskName"))}" title="${escapeAttr(t("copyTaskName"))}">
|
|
417
|
+
${t("copyTaskNameShort")}
|
|
418
|
+
</button>`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function taskGroupTimeKey(group) {
|
|
422
|
+
const match = group.match(/^(?:month|legacy):(\d{4})-(\d{2})$/);
|
|
423
|
+
if (!match) return null;
|
|
424
|
+
return Date.UTC(Number(match[1]), Number(match[2]) - 1, 1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function taskToolbarCard(filteredCount) {
|
|
428
|
+
return `<section class="sidebar-card">
|
|
429
|
+
<h3>${t("filterTitle")}</h3>
|
|
430
|
+
<div class="input-group">
|
|
431
|
+
<input data-search value="${escapeAttr(state.query)}" placeholder="${t("searchPlaceholder")}" aria-label="${t("searchTasks")}">
|
|
432
|
+
</div>
|
|
433
|
+
<div class="select-group">
|
|
434
|
+
<label>${t("stateFilter")}</label>
|
|
435
|
+
<select data-state-filter aria-label="${t("stateFilter")}">
|
|
436
|
+
${["all", "in_progress", "review", "blocked", "planned", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
|
|
437
|
+
</select>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="select-group">
|
|
440
|
+
<label>${t("groupBy")}</label>
|
|
441
|
+
<select data-group-mode aria-label="${t("groupBy")}">
|
|
442
|
+
${["migration", "module", "month", "state"].map((value) => `<option value="${value}" ${state.taskGroupMode === value ? "selected" : ""}>${t(`group_${value}`)}</option>`).join("")}
|
|
443
|
+
</select>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="select-group">
|
|
446
|
+
<label>${t("layout")}</label>
|
|
447
|
+
<div class="layout-toggle-group">
|
|
448
|
+
<button class="layout-btn ${state.taskLayout === "list" ? "active" : ""}" data-layout="list" aria-label="${t("layoutList")}">
|
|
449
|
+
<svg style="width:12px;height:12px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
|
450
|
+
${t("layoutList")}
|
|
451
|
+
</button>
|
|
452
|
+
<button class="layout-btn ${state.taskLayout === "grid" ? "active" : ""}" data-layout="grid" aria-label="${t("layoutGrid")}">
|
|
453
|
+
<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>
|
|
454
|
+
${t("layoutGrid")}
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
<div class="select-group">
|
|
459
|
+
<label>${t("sortByTime")}</label>
|
|
460
|
+
<div class="layout-toggle-group sort-toggle-group">
|
|
461
|
+
<button class="layout-btn ${state.taskSortOrder === "desc" ? "active" : ""}" data-task-sort-order="desc" aria-label="${t("sortNewest")}">
|
|
462
|
+
${t("sortNewest")}
|
|
463
|
+
</button>
|
|
464
|
+
<button class="layout-btn ${state.taskSortOrder === "asc" ? "active" : ""}" data-task-sort-order="asc" aria-label="${t("sortOldest")}">
|
|
465
|
+
${t("sortOldest")}
|
|
466
|
+
</button>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="search-stats">
|
|
470
|
+
${t("showing")} <strong>${filteredCount}</strong> / ${(bundle.status?.tasks || []).length} ${t("tasks")}
|
|
471
|
+
</div>
|
|
147
472
|
</section>`;
|
|
148
473
|
}
|
|
149
474
|
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
475
|
+
function taskStatsCard() {
|
|
476
|
+
const allTasks = bundle.status?.tasks || [];
|
|
477
|
+
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
478
|
+
return `<section class="sidebar-card">
|
|
479
|
+
<h3>${t("releaseHealth")}</h3>
|
|
480
|
+
<div class="stats-hero-gauge">
|
|
481
|
+
<span class="gauge-percentage">${avgCompletion}%</span>
|
|
482
|
+
<span class="gauge-label">${t("statOverall")}</span>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="stats-breakdown">
|
|
485
|
+
${[
|
|
486
|
+
{ state: "in_progress", label: t("statInProgress"), colorVar: "--accent" },
|
|
487
|
+
{ state: "review", label: t("statReview"), colorVar: "--accent-2" },
|
|
488
|
+
{ state: "blocked", label: t("statBlocked"), colorVar: "--danger" },
|
|
489
|
+
{ state: "done", label: t("statDone"), colorVar: "--ok" }
|
|
490
|
+
].map(({ state, label, colorVar }) => {
|
|
491
|
+
const count = allTasks.filter(t => t.state === state).length;
|
|
492
|
+
return `<div class="stats-breakdown-row">
|
|
493
|
+
<span class="stat-label">
|
|
494
|
+
<span class="state-dot" style="background:var(${colorVar})"></span>
|
|
495
|
+
${label}
|
|
496
|
+
</span>
|
|
497
|
+
<span class="stat-value">${count}</span>
|
|
498
|
+
</div>`;
|
|
499
|
+
}).join("")}
|
|
500
|
+
</div>
|
|
174
501
|
</section>`;
|
|
175
502
|
}
|
|
176
503
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
504
|
+
function taskLegendCard() {
|
|
505
|
+
return `<section class="sidebar-card">
|
|
506
|
+
<h3>${t("legendTitle")}</h3>
|
|
507
|
+
<div class="legend-list">
|
|
508
|
+
<div class="legend-item">
|
|
509
|
+
<span class="badge brief ready" style="margin-top:2px">
|
|
510
|
+
<svg style="width:10px;height:10px;margin-right:2px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
511
|
+
${t("badgeBrief")}
|
|
512
|
+
</span>
|
|
513
|
+
<span>${t("legendBriefDesc")}</span>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="legend-item">
|
|
516
|
+
<span class="badge map ready" style="margin-top:2px">
|
|
517
|
+
<svg style="width:10px;height:10px;margin-right:2px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
518
|
+
${t("badgeMap")}
|
|
519
|
+
</span>
|
|
520
|
+
<span>${t("legendMapDesc")}</span>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
</section>`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function taskStatsBar() {
|
|
527
|
+
const allTasks = bundle.status?.tasks || [];
|
|
528
|
+
const inProgress = allTasks.filter(t => t.state === "in_progress").length;
|
|
529
|
+
const blocked = allTasks.filter(t => t.state === "blocked").length;
|
|
530
|
+
const done = allTasks.filter(t => t.state === "done").length;
|
|
531
|
+
const review = allTasks.filter(t => t.state === "review").length;
|
|
532
|
+
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
533
|
+
|
|
534
|
+
return `<section class="task-stats-bar">
|
|
535
|
+
<div class="stat-chip">
|
|
536
|
+
<span class="stat-value">${allTasks.length}</span>
|
|
537
|
+
<span class="stat-label">${t("statTotal")}</span>
|
|
538
|
+
</div>
|
|
539
|
+
<div class="stat-chip in-progress">
|
|
540
|
+
<span class="stat-value">${inProgress}</span>
|
|
541
|
+
<span class="stat-label">${t("statInProgress")}</span>
|
|
542
|
+
</div>
|
|
543
|
+
<div class="stat-chip review">
|
|
544
|
+
<span class="stat-value">${review}</span>
|
|
545
|
+
<span class="stat-label">${t("statReview")}</span>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="stat-chip blocked">
|
|
548
|
+
<span class="stat-value">${blocked}</span>
|
|
549
|
+
<span class="stat-label">${t("statBlocked")}</span>
|
|
550
|
+
</div>
|
|
551
|
+
<div class="stat-chip done">
|
|
552
|
+
<span class="stat-value">${done}</span>
|
|
553
|
+
<span class="stat-label">${t("statDone")}</span>
|
|
554
|
+
</div>
|
|
555
|
+
<div class="stat-chip completion">
|
|
556
|
+
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${avgCompletion}%"></div></div>
|
|
557
|
+
<div style="text-align:right">
|
|
558
|
+
<span class="stat-value">${avgCompletion}%</span>
|
|
559
|
+
<span class="stat-label" style="display:block;margin-top:2px">${t("statOverall")}</span>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
</section>`;
|
|
231
563
|
}
|
|
232
564
|
|
|
233
|
-
function
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
565
|
+
function taskRow(task) {
|
|
566
|
+
const completion = clampCompletion(task.completion);
|
|
567
|
+
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
|
|
568
|
+
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
569
|
+
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
570
|
+
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
571
|
+
|
|
572
|
+
return `<article class="task-row-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
573
|
+
<div class="row-accent-bar"></div>
|
|
574
|
+
<div class="row-main">
|
|
575
|
+
<strong>${escapeHtml(task.title)}</strong>
|
|
576
|
+
<span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(taskModuleKey(task))}</span>
|
|
577
|
+
${taskCopyButton(task, "row-copy")}
|
|
578
|
+
</div>
|
|
579
|
+
<div class="row-status">${tag(task.state)}</div>
|
|
580
|
+
<div class="row-progress">
|
|
581
|
+
<div class="mini-progress-track"><div class="mini-progress-fill" style="width:${completion}%"></div></div>
|
|
582
|
+
<span class="row-pct">${completion}%</span>
|
|
583
|
+
</div>
|
|
584
|
+
<div class="row-brief ${briefReady ? "ready" : "missing"}" title="${escapeAttr(briefLabel)}" aria-label="${escapeAttr(briefLabel)}">
|
|
585
|
+
<span class="badge brief ${briefReady ? "ready" : "missing"}">
|
|
586
|
+
<svg style="width:10px;height:10px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
587
|
+
${briefReady ? t("badgeBrief") : t("badgeBriefMissing")}
|
|
588
|
+
</span>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="row-map ${mapReady ? "ready" : "missing"}" title="${escapeAttr(mapLabel)}" aria-label="${escapeAttr(mapLabel)}">
|
|
591
|
+
<span class="badge map ${mapReady ? "ready" : "missing"}">
|
|
592
|
+
<svg style="width:10px;height:10px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
593
|
+
${mapReady ? t("badgeMap") : t("badgeMapMissing")}
|
|
594
|
+
</span>
|
|
595
|
+
</div>
|
|
596
|
+
</article>`;
|
|
237
597
|
}
|
|
238
598
|
|
|
239
|
-
function
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<
|
|
599
|
+
function taskIndex() {
|
|
600
|
+
const tasks = filteredTasks();
|
|
601
|
+
const groups = taskGroups(tasks);
|
|
602
|
+
const orderedGroups = orderedTaskGroups(groups);
|
|
603
|
+
const groupPageCount = Math.max(1, Math.ceil(orderedGroups.length / taskGroupsPerPage));
|
|
604
|
+
const groupPage = Math.min(Math.max(1, Number(state.taskGroupPage) || 1), groupPageCount);
|
|
605
|
+
const visibleGroups = orderedGroups.slice((groupPage - 1) * taskGroupsPerPage, groupPage * taskGroupsPerPage);
|
|
606
|
+
|
|
607
|
+
return `<div class="tasks-grid">
|
|
608
|
+
<div class="tasks-main stack">
|
|
609
|
+
${taskStatsBar()}
|
|
610
|
+
${visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
|
|
611
|
+
<section class="group-pager">
|
|
612
|
+
<span>${t("showingGroups")} ${visibleGroups.length ? (groupPage - 1) * taskGroupsPerPage + 1 : 0}-${Math.min(groupPage * taskGroupsPerPage, orderedGroups.length)} / ${orderedGroups.length}</span>
|
|
613
|
+
${pager("task-groups", groupPage, groupPageCount)}
|
|
253
614
|
</section>
|
|
254
615
|
</div>
|
|
255
|
-
|
|
616
|
+
<aside class="tasks-sidebar stack">
|
|
617
|
+
${taskToolbarCard(tasks.length)}
|
|
618
|
+
${taskStatsCard()}
|
|
619
|
+
${taskLegendCard()}
|
|
620
|
+
</aside>
|
|
621
|
+
</div>`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function orderedTaskGroups(groups) {
|
|
625
|
+
const rank = (group) => {
|
|
626
|
+
if (group.startsWith("module:")) return 2;
|
|
627
|
+
if (group.startsWith("state:")) return 2;
|
|
628
|
+
if (group.startsWith("month:")) return 2;
|
|
629
|
+
if (group === "active") return 0;
|
|
630
|
+
if (group === "brief-ready") return 1;
|
|
631
|
+
if (group.startsWith("legacy:")) return 2;
|
|
632
|
+
if (group === "unknown") return 3;
|
|
633
|
+
return 4;
|
|
634
|
+
};
|
|
635
|
+
return Object.entries(groups).sort(([left], [right]) => {
|
|
636
|
+
const rankDiff = rank(left) - rank(right);
|
|
637
|
+
if (rankDiff !== 0) return rankDiff;
|
|
638
|
+
const leftTime = taskGroupTimeKey(left);
|
|
639
|
+
const rightTime = taskGroupTimeKey(right);
|
|
640
|
+
if (leftTime !== null && rightTime !== null && leftTime !== rightTime) {
|
|
641
|
+
return state.taskSortOrder === "asc" ? leftTime - rightTime : rightTime - leftTime;
|
|
642
|
+
}
|
|
643
|
+
if (leftTime !== null && rightTime === null) return -1;
|
|
644
|
+
if (leftTime === null && rightTime !== null) return 1;
|
|
645
|
+
return left.localeCompare(right);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function taskGroups(tasks) {
|
|
650
|
+
if (state.taskGroupMode === "module") {
|
|
651
|
+
return groupBy(tasks, (task) => `module:${taskModuleKey(task)}`);
|
|
652
|
+
}
|
|
653
|
+
if (state.taskGroupMode === "month") {
|
|
654
|
+
return groupBy(tasks, (task) => {
|
|
655
|
+
const match = task.shortId?.match(/^(\d{4}-\d{2})/);
|
|
656
|
+
return match ? `month:${match[1]}` : "month:unknown";
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (state.taskGroupMode === "state") {
|
|
660
|
+
return groupBy(tasks, (task) => `state:${task.state || "unknown"}`);
|
|
661
|
+
}
|
|
662
|
+
return groupBy(tasks, (task) => {
|
|
663
|
+
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)) return "active";
|
|
664
|
+
if (task.briefSource === "standalone") return "brief-ready";
|
|
665
|
+
const match = task.shortId?.match(/^(\d{4}-\d{2})/);
|
|
666
|
+
return match ? `legacy:${match[1]}` : task.state || "unknown";
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function taskGroup(group, tasks) {
|
|
671
|
+
const orderedTasks = sortTasksByTime(tasks);
|
|
672
|
+
const pageCount = Math.max(1, Math.ceil(orderedTasks.length / taskPageSize));
|
|
673
|
+
const page = Math.min(Math.max(1, Number(state.taskPageByGroup[group]) || 1), pageCount);
|
|
674
|
+
const start = (page - 1) * taskPageSize;
|
|
675
|
+
const visibleTasks = orderedTasks.slice(start, start + taskPageSize);
|
|
676
|
+
const avgCompletion = orderedTasks.length ? clampCompletion(orderedTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / orderedTasks.length) : 0;
|
|
677
|
+
|
|
678
|
+
const isGrid = state.taskLayout === "grid";
|
|
679
|
+
const layoutClass = isGrid ? "task-card-grid" : "task-list";
|
|
680
|
+
const itemRenderer = isGrid ? taskCard : taskRow;
|
|
681
|
+
const listHeader = isGrid ? "" : `<div class="task-list-header">
|
|
682
|
+
<div class="col-main">${t("columnTask")}</div>
|
|
683
|
+
<div class="col-status">${t("columnState")}</div>
|
|
684
|
+
<div class="col-progress">${t("columnCompletion")}</div>
|
|
685
|
+
<div class="col-brief">${t("columnBrief")}</div>
|
|
686
|
+
<div class="col-map">${t("badgeMap")}</div>
|
|
687
|
+
</div>`;
|
|
688
|
+
|
|
689
|
+
return `<section class="task-group">
|
|
690
|
+
<div class="section-head">
|
|
691
|
+
<div>
|
|
692
|
+
<h2>${taskGroupLabel(group)}</h2>
|
|
693
|
+
<p class="subtle">${t("showing")} ${Math.min(start + 1, orderedTasks.length)}-${Math.min(start + visibleTasks.length, orderedTasks.length)} / ${orderedTasks.length}</p>
|
|
694
|
+
</div>
|
|
695
|
+
<div class="group-actions">
|
|
696
|
+
<div class="group-progress" aria-label="${escapeAttr(t("groupCompletion"))}">
|
|
697
|
+
<div class="group-progress-track"><div class="group-progress-fill" style="width:${avgCompletion}%"></div></div>
|
|
698
|
+
<span>${avgCompletion}%</span>
|
|
699
|
+
</div>
|
|
700
|
+
${pager("task", page, pageCount, group)}
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
<div class="${layoutClass}">
|
|
704
|
+
${listHeader}
|
|
705
|
+
${visibleTasks.map(itemRenderer).join("")}
|
|
706
|
+
</div>
|
|
707
|
+
</section>`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function taskCard(task) {
|
|
711
|
+
const completion = clampCompletion(task.completion);
|
|
712
|
+
const stateColor = stateToColorVar(task.state);
|
|
713
|
+
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
|
|
714
|
+
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
715
|
+
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
716
|
+
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
717
|
+
|
|
718
|
+
return `<article class="task-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateColor})">
|
|
719
|
+
<div class="card-header">
|
|
720
|
+
<span class="card-id">${escapeHtml(task.id)}</span>
|
|
721
|
+
<div class="card-header-actions">
|
|
722
|
+
${taskCopyButton(task, "compact")}
|
|
723
|
+
${tag(task.state)}
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
<h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
|
|
727
|
+
<div class="card-meta">
|
|
728
|
+
<span class="meta-module" title="${escapeAttr(taskModuleKey(task))}">
|
|
729
|
+
<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>
|
|
730
|
+
${escapeHtml(taskModuleKey(task))}
|
|
731
|
+
</span>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="card-progress">
|
|
734
|
+
<div class="card-progress-track"><div class="card-progress-fill" style="width:${completion}%"></div></div>
|
|
735
|
+
<span class="progress-pct">${completion}%</span>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="card-badges">
|
|
738
|
+
<span class="badge brief ${briefReady ? "ready" : "missing"}" title="${escapeAttr(briefLabel)}">
|
|
739
|
+
<svg style="width:10px;height:10px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
740
|
+
${briefReady ? t("badgeBrief") : t("badgeBriefMissing")}
|
|
741
|
+
</span>
|
|
742
|
+
<span class="badge map ${mapReady ? "ready" : "missing"}" title="${escapeAttr(mapLabel)}">
|
|
743
|
+
<svg style="width:10px;height:10px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
744
|
+
${mapReady ? t("badgeMap") : t("badgeMapMissing")}
|
|
745
|
+
</span>
|
|
746
|
+
</div>
|
|
747
|
+
</article>`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function taskGroupLabel(group) {
|
|
751
|
+
if (group === "active") return t("activeCurrent");
|
|
752
|
+
if (group === "brief-ready") return t("briefReadyGroup");
|
|
753
|
+
if (group.startsWith("legacy:")) return `${t("legacyMonth")} ${group.slice("legacy:".length)}`;
|
|
754
|
+
if (group.startsWith("module:")) return `${t("inferredModule")} · ${group.slice("module:".length)}`;
|
|
755
|
+
if (group.startsWith("month:")) return `${t("legacyMonth")} ${group.slice("month:".length)}`;
|
|
756
|
+
if (group.startsWith("state:")) return `${t("columnState")} · ${label(group.slice("state:".length))}`;
|
|
757
|
+
return label(group);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function filteredTasks() {
|
|
761
|
+
const query = state.query.trim().toLowerCase();
|
|
762
|
+
return sortTasksByTime((bundle.status?.tasks || []).filter((task) => {
|
|
763
|
+
const stateMatch = state.taskState === "all" || task.state === state.taskState;
|
|
764
|
+
if (!stateMatch) return false;
|
|
765
|
+
if (!query) return true;
|
|
766
|
+
return [task.id, task.shortId, task.title, task.module, task.inferredModule, task.classificationSource, task.classificationBucket, task.state].some((value) => String(value || "").toLowerCase().includes(query));
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function taskModuleKey(task) {
|
|
771
|
+
return task.module || task.inferredModule || "legacy-unclassified";
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function taskDetail(route) {
|
|
775
|
+
const taskId = route.id;
|
|
776
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
777
|
+
if (!task) return `<main>${emptyState(t("taskNotFound"))}</main>`;
|
|
778
|
+
return `<main class="task-detail">
|
|
779
|
+
<nav class="crumbs"><a href="#/tasks">${t("taskIndex")}</a><span>/</span><span>${escapeHtml(task.id)}</span></nav>
|
|
780
|
+
<section class="detail-hero">
|
|
781
|
+
<div>
|
|
782
|
+
<p class="eyebrow">${t("taskVisibility")}</p>
|
|
783
|
+
<h2>${escapeHtml(task.title)}</h2>
|
|
784
|
+
<p>${escapeHtml(task.path)}</p>
|
|
785
|
+
${taskCopyButton(task, "detail-copy")}
|
|
786
|
+
</div>
|
|
787
|
+
<div class="detail-score">${task.completion}%</div>
|
|
788
|
+
</section>
|
|
789
|
+
${taskStateSummary(task)}
|
|
790
|
+
${phaseTimeline(task)}
|
|
791
|
+
<section class="detail-grid">
|
|
792
|
+
<article class="detail-main">
|
|
793
|
+
${taskDocumentLibrary(task, route.doc)}
|
|
794
|
+
</article>
|
|
795
|
+
<aside class="detail-side">
|
|
796
|
+
${reviewActionPanel(task, { mode: "summary" })}
|
|
797
|
+
${lessonCandidatePanel(task, { context: "detail" })}
|
|
798
|
+
${openFindings(task)}
|
|
799
|
+
${evidenceList(task)}
|
|
800
|
+
${documentTabs(task)}
|
|
801
|
+
</aside>
|
|
802
|
+
</section>
|
|
803
|
+
</main>`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function taskStateSummary(task) {
|
|
807
|
+
return `<section class="task-state-summary">
|
|
808
|
+
<div>
|
|
809
|
+
<span>${t("legacyState")}</span>
|
|
810
|
+
${tag(task.state)}
|
|
811
|
+
</div>
|
|
812
|
+
<div>
|
|
813
|
+
<span>${t("lifecycleState")}</span>
|
|
814
|
+
${tag(task.lifecycleState || "unknown")}
|
|
815
|
+
</div>
|
|
816
|
+
<div>
|
|
817
|
+
<span>${t("reviewStatus")}</span>
|
|
818
|
+
${tag(task.reviewStatus || "missing")}
|
|
819
|
+
</div>
|
|
820
|
+
<div>
|
|
821
|
+
<span>${t("sedimentationStatus")}</span>
|
|
822
|
+
${tag(task.lessonCandidateStatus || "missing")}
|
|
823
|
+
</div>
|
|
824
|
+
<div>
|
|
825
|
+
<span>${t("closeoutStatus")}</span>
|
|
826
|
+
${tag(task.closeoutStatus || "missing")}
|
|
827
|
+
</div>
|
|
828
|
+
<div>
|
|
829
|
+
<span>${t("lifecycleQueues")}</span>
|
|
830
|
+
${(task.taskQueues || []).map(tag).join("") || tag("active")}
|
|
831
|
+
</div>
|
|
832
|
+
${taskQueueReasonSummary(task)}
|
|
833
|
+
</section>`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function taskQueueReasonSummary(task) {
|
|
837
|
+
const reasons = task.queueReasons || [];
|
|
838
|
+
if (!reasons.length) return "";
|
|
839
|
+
return `<div class="task-queue-reasons">
|
|
840
|
+
<span>${t("queueReasons")}</span>
|
|
841
|
+
<div class="review-reasons">
|
|
842
|
+
${reasons.slice(0, 5).map(reviewReason).join("")}
|
|
843
|
+
</div>
|
|
844
|
+
</div>`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function phaseTimeline(task) {
|
|
848
|
+
return `<section class="phase-timeline">
|
|
849
|
+
<h2>${t("phaseTimeline")}</h2>
|
|
850
|
+
${(task.phases || []).map((phase) => `<div class="phase-step ${phase.state}">
|
|
851
|
+
<strong>${escapeHtml(phase.id)}</strong>
|
|
852
|
+
<span>${phase.completion}%</span>
|
|
853
|
+
<p>${escapeHtml(phase.output || phase.blockingRisk || phase.state)}</p>
|
|
854
|
+
${progressBar(phase.completion)}
|
|
855
|
+
</div>`).join("") || emptyState(t("noPhaseData"))}
|
|
856
|
+
</section>`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function taskDocSection(task, fileName, title, required) {
|
|
860
|
+
const doc = taskDocument(task, fileName);
|
|
861
|
+
if (!doc && !required) return "";
|
|
862
|
+
return `<section class="doc-section">
|
|
863
|
+
<div class="section-head"><h2>${escapeHtml(title)}</h2>${doc ? `<button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button>` : ""}</div>
|
|
864
|
+
<div class="markdown">${doc ? window.HarnessMarkdown.render(doc.content, state.renderMode) : generatedBrief(task)}</div>
|
|
865
|
+
</section>`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function taskDocumentLibrary(task, selectedTab) {
|
|
869
|
+
const docs = orderedTaskDocuments(task);
|
|
870
|
+
if (!docs.length) return taskDocSection(task, "brief.md", t("brief"), true);
|
|
871
|
+
const selectedKey = docs.some((doc) => doc.key === selectedTab) ? selectedTab : defaultTaskDocumentKey(task, docs);
|
|
872
|
+
return `<section class="doc-library">
|
|
873
|
+
<div class="section-head">
|
|
874
|
+
<div>
|
|
875
|
+
<p class="eyebrow">${t("taskDocuments")}</p>
|
|
876
|
+
<h2>${escapeHtml(t("sourceDocuments"))}</h2>
|
|
877
|
+
</div>
|
|
878
|
+
<button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button>
|
|
879
|
+
</div>
|
|
880
|
+
<div class="doc-accordion-list">
|
|
881
|
+
${docs.map((item) => documentAccordion(item, item.key === selectedKey)).join("")}
|
|
882
|
+
</div>
|
|
883
|
+
</section>`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function orderedTaskDocuments(task) {
|
|
887
|
+
const docs = taskDocTabs
|
|
888
|
+
.map(([key, file]) => {
|
|
889
|
+
const doc = taskDocument(task, file);
|
|
890
|
+
if (doc) return { key, file, title: t(key), path: doc.path, content: doc.content };
|
|
891
|
+
if (key === "brief") return { key, file, title: t(key), path: `${task.path}/brief.md`, content: generatedBrief(task), generated: true };
|
|
892
|
+
return null;
|
|
893
|
+
})
|
|
894
|
+
.filter(Boolean);
|
|
895
|
+
const priority = taskDocumentPriority(task);
|
|
896
|
+
const rank = new Map(priority.map((key, index) => [key, index]));
|
|
897
|
+
return docs.sort((a, b) => (rank.get(a.key) ?? 99) - (rank.get(b.key) ?? 99));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function taskDocumentPriority(task) {
|
|
901
|
+
const stateName = task?.state || "";
|
|
902
|
+
const lifecycle = task?.lifecycleState || "";
|
|
903
|
+
if (stateName === "review" || ["in_review", "review-blocked"].includes(lifecycle)) {
|
|
904
|
+
return ["walkthrough", "lessonCandidates", "review", "findings", "visualMap", "progress", "brief", "taskPlan", "strategy", "longRunningContract", "legacyRoadmap", "references", "artifacts"];
|
|
905
|
+
}
|
|
906
|
+
if (stateName === "in_progress" || lifecycle === "active" || stateName === "blocked") {
|
|
907
|
+
return ["progress", "visualMap", "brief", "taskPlan", "strategy", "findings", "review", "walkthrough", "references", "artifacts", "legacyRoadmap"];
|
|
908
|
+
}
|
|
909
|
+
if (stateName === "done" || ["closing", "closed"].includes(lifecycle)) {
|
|
910
|
+
return ["walkthrough", "progress", "review", "findings", "visualMap", "brief", "taskPlan", "strategy", "references", "artifacts", "legacyRoadmap"];
|
|
911
|
+
}
|
|
912
|
+
return ["brief", "taskPlan", "visualMap", "strategy", "progress", "findings", "review", "walkthrough", "references", "artifacts", "legacyRoadmap"];
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function defaultTaskDocumentKey(task, docs) {
|
|
916
|
+
const priority = taskDocumentPriority(task);
|
|
917
|
+
return priority.find((key) => docs.some((doc) => doc.key === key)) || docs[0]?.key || "brief";
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function documentAccordion(item, open) {
|
|
921
|
+
return `<details class="doc-accordion" ${open ? "open" : ""}>
|
|
922
|
+
<summary>
|
|
923
|
+
<span>${escapeHtml(item.title)}</span>
|
|
924
|
+
<small>${escapeHtml(item.generated ? t("generatedFallback") : item.path)}</small>
|
|
925
|
+
</summary>
|
|
926
|
+
<div class="markdown">${window.HarnessMarkdown.render(item.content, state.renderMode)}</div>
|
|
927
|
+
</details>`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function documentTabs(task) {
|
|
931
|
+
const docs = orderedTaskDocuments(task);
|
|
932
|
+
return `<section class="side-panel">
|
|
933
|
+
<h3>${t("sourceDocuments")}</h3>
|
|
934
|
+
${docs.map((doc) => `<a href="#/tasks/${encodeURIComponent(task.id)}/docs/${encodeURIComponent(doc.key)}" title="${escapeAttr(doc.path)}">${escapeHtml(doc.title)}</a>`).join("") || `<p>${t("noDocuments")}</p>`}
|
|
256
935
|
</section>`;
|
|
257
936
|
}
|
|
258
937
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
938
|
+
function selectedSourceDocument(task, tab) {
|
|
939
|
+
if (!tab) return "";
|
|
940
|
+
const match = taskDocTabs.find(([key]) => key === tab);
|
|
941
|
+
if (!match) return "";
|
|
942
|
+
const doc = taskDocument(task, match[1]);
|
|
943
|
+
if (!doc) return "";
|
|
944
|
+
return `<section class="doc-section selected-source">
|
|
945
|
+
<div class="section-head"><h2>${t("selectedSource")} · ${t(match[0])}</h2><button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button></div>
|
|
946
|
+
<div class="markdown">${window.HarnessMarkdown.render(doc.content, state.renderMode)}</div>
|
|
264
947
|
</section>`;
|
|
265
948
|
}
|
|
266
949
|
|
|
267
|
-
function
|
|
268
|
-
|
|
950
|
+
function openFindings(task) {
|
|
951
|
+
const risks = task.risks || [];
|
|
952
|
+
return `<section class="side-panel">
|
|
953
|
+
<h3>${t("openFindings")}</h3>
|
|
954
|
+
${risks.map((risk) => `<div class="finding ${risk.open || risk.blocksRelease ? "open" : ""}"><strong>${escapeHtml(risk.severity)}</strong><span>${escapeHtml(risk.summary)}</span></div>`).join("") || `<p>${t("noOpenFindings")}</p>`}
|
|
955
|
+
</section>`;
|
|
269
956
|
}
|
|
270
957
|
|
|
271
|
-
function
|
|
272
|
-
return
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
958
|
+
function reviewActionPanel(task, { mode = "summary" } = {}) {
|
|
959
|
+
if (!isTaskInReviewQueue(task)) return "";
|
|
960
|
+
const blocking = task.reviewStatus === "blocked-open-findings" || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
|
|
961
|
+
const confirmed = task.reviewStatus === "confirmed";
|
|
962
|
+
const candidateBlocked = task.budget !== "simple" && !task.lessonCandidateDecisionComplete;
|
|
963
|
+
const candidateStatus = task.lessonCandidateStatus || "missing";
|
|
964
|
+
if (mode !== "workspace") {
|
|
965
|
+
return `<section class="side-panel review-actions">
|
|
966
|
+
<h3>${t("reviewActions")}</h3>
|
|
967
|
+
<p>${escapeHtml(confirmed ? t("reviewAlreadyConfirmed") : t("reviewOpenInWorkspace"))}</p>
|
|
968
|
+
<p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
|
|
969
|
+
<a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
|
|
970
|
+
</section>`;
|
|
971
|
+
}
|
|
972
|
+
if (!canUseWorkbenchAction("review-complete")) {
|
|
973
|
+
return `<section class="side-panel review-actions">
|
|
974
|
+
<h3>${t("reviewActions")}</h3>
|
|
975
|
+
<p>${escapeHtml(t("staticReadOnlyDetail"))}</p>
|
|
976
|
+
</section>`;
|
|
977
|
+
}
|
|
978
|
+
if (confirmed) {
|
|
979
|
+
return `<section class="side-panel review-actions">
|
|
980
|
+
<h3>${t("reviewActions")}</h3>
|
|
981
|
+
<p>${escapeHtml(t("reviewAlreadyConfirmed"))}</p>
|
|
982
|
+
</section>`;
|
|
983
|
+
}
|
|
984
|
+
const missingWalkthrough = task.budget !== "simple" && !task.walkthroughPath;
|
|
985
|
+
const queueBlocked = !taskCanBeHumanConfirmed(task);
|
|
986
|
+
const disabled = blocking || missingWalkthrough || candidateBlocked || queueBlocked;
|
|
987
|
+
const message = missingWalkthrough ? t("reviewWalkthroughRequired") : blocking ? t("reviewBlocked") : candidateBlocked ? t("reviewCandidateDecisionRequired") : queueBlocked ? t("reviewQueueRequired") : t("reviewWorkbenchReady");
|
|
988
|
+
return `<section class="side-panel review-actions">
|
|
989
|
+
<h3>${t("reviewActions")}</h3>
|
|
990
|
+
<p>${escapeHtml(message)}</p>
|
|
991
|
+
<p>${escapeHtml(t("lessonCandidateStatus"))}: ${tag(candidateStatus)}</p>
|
|
992
|
+
<label class="review-check">
|
|
993
|
+
<input type="checkbox" data-review-confirm-check="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>
|
|
994
|
+
<span>${t("reviewConfirmChecklist")}</span>
|
|
995
|
+
</label>
|
|
996
|
+
<div class="review-confirm-copy">
|
|
997
|
+
${taskCopyButton(task, "review-copy-task-name")}
|
|
998
|
+
</div>
|
|
999
|
+
<input data-review-confirm-text="${escapeAttr(task.id)}" value="" placeholder="${escapeAttr(task.shortId || task.id)}" ${disabled ? "disabled" : ""}>
|
|
1000
|
+
<button data-review-complete="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${t("confirmReviewComplete")}</button>
|
|
1001
|
+
<div class="review-result" data-review-result="${escapeAttr(task.id)}"></div>
|
|
278
1002
|
</section>`;
|
|
279
1003
|
}
|
|
280
1004
|
|
|
281
|
-
function
|
|
282
|
-
return
|
|
1005
|
+
function isTaskInReviewQueue(task) {
|
|
1006
|
+
return (task?.reviewQueueState || "not-in-queue") !== "not-in-queue";
|
|
283
1007
|
}
|
|
284
1008
|
|
|
285
|
-
function
|
|
1009
|
+
function taskCanBeHumanConfirmed(task) {
|
|
1010
|
+
return task?.reviewQueueState === "ready-to-confirm" && Array.isArray(task?.taskQueues) && task.taskQueues.includes("review");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function evidenceList(task) {
|
|
1014
|
+
const evidence = task.evidence || [];
|
|
1015
|
+
return `<section class="side-panel">
|
|
1016
|
+
<h3>${t("evidence")}</h3>
|
|
1017
|
+
${evidence.map((item) => `<p><strong>${escapeHtml(item.type || "evidence")}</strong> ${escapeHtml(item.summary || "")}</p>`).join("") || `<p>${t("noEvidence")}</p>`}
|
|
1018
|
+
</section>`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function modulesView(moduleId = "") {
|
|
286
1022
|
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1023
|
+
const explicitModules = (graph.nodes || []).filter((node) => node.type === "module");
|
|
1024
|
+
const moduleMap = new Map(explicitModules.map((module) => [module.id.replace(/^module:/, ""), module]));
|
|
1025
|
+
for (const task of bundle.status?.tasks || []) {
|
|
1026
|
+
const key = taskModuleKey(task);
|
|
1027
|
+
if (!moduleMap.has(key)) moduleMap.set(key, { id: `module:${key}`, type: "module", label: key, state: task.classificationSource || "inferred" });
|
|
1028
|
+
}
|
|
1029
|
+
const modules = [...moduleMap.values()];
|
|
1030
|
+
return `<main class="stack">
|
|
1031
|
+
<section class="module-grid">
|
|
1032
|
+
${modules.map((module) => moduleCard(module)).join("") || emptyState(t("noModules"))}
|
|
1033
|
+
</section>
|
|
1034
|
+
</main>`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function moduleTaskRow(task) {
|
|
1038
|
+
const dotClass = /fail|blocked|open/i.test(task.state) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(task.state) ? "state-warn" : "state-pass";
|
|
1039
|
+
return `<a class="module-task-row" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}">
|
|
1040
|
+
<div class="module-task-left">
|
|
1041
|
+
<i class="module-task-dot ${dotClass}" title="${escapeAttr(task.state)}"></i>
|
|
1042
|
+
<span class="module-task-title">${escapeHtml(task.title)}</span>
|
|
1043
|
+
</div>
|
|
1044
|
+
<span class="module-task-pct">${task.completion}%</span>
|
|
1045
|
+
</a>`;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function moduleCard(module) {
|
|
1049
|
+
const moduleKey = module.id.replace(/^module:/, "");
|
|
1050
|
+
const tasks = (bundle.status?.tasks || []).filter((task) => taskModuleKey(task) === moduleKey);
|
|
1051
|
+
|
|
1052
|
+
// Inline Pagination
|
|
1053
|
+
state.modulePages = state.modulePages || {};
|
|
1054
|
+
const currentPage = state.modulePages[moduleKey] || 1;
|
|
1055
|
+
const pageCount = Math.ceil(tasks.length / 8) || 1;
|
|
1056
|
+
const visibleTasks = tasks.slice((currentPage - 1) * 8, currentPage * 8);
|
|
1057
|
+
|
|
1058
|
+
const brief = findDocument(`TARGET:docs/09-PLANNING/MODULES/${moduleKey}/brief.md`);
|
|
1059
|
+
|
|
1060
|
+
let pagerHtml = "";
|
|
1061
|
+
if (tasks.length > 8) {
|
|
1062
|
+
pagerHtml = `<div class="module-pager">
|
|
1063
|
+
<button ${currentPage <= 1 ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage - 1})">${t("prevPage")}</button>
|
|
1064
|
+
<span>${currentPage} / ${pageCount}</span>
|
|
1065
|
+
<button ${currentPage >= pageCount ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage + 1})">${t("nextPage")}</button>
|
|
1066
|
+
</div>`;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return `<article class="module-card">
|
|
1070
|
+
<div class="card-head"><h2>${escapeHtml(module.label || moduleKey)}</h2>${tag(module.state || "unknown")}</div>
|
|
1071
|
+
<div class="markdown">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
|
|
1072
|
+
<h3>${t("moduleTasks")} · ${tasks.length}</h3>
|
|
1073
|
+
<div class="module-task-list">
|
|
1074
|
+
${visibleTasks.map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
|
|
1075
|
+
</div>
|
|
1076
|
+
${pagerHtml}
|
|
1077
|
+
</article>`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function reviewQueue() {
|
|
1081
|
+
ensureReviewQueueState();
|
|
1082
|
+
const tabs = reviewQueueTabs();
|
|
1083
|
+
const activeTab = tabs.find((tab) => tab.id === state.reviewQueueTab) || tabs[0];
|
|
1084
|
+
const baseTasks = reviewQueueBaseTasks(activeTab);
|
|
1085
|
+
const reasonOptions = reviewReasonOptions(baseTasks);
|
|
1086
|
+
normalizeReviewReasonFilter(reasonOptions);
|
|
1087
|
+
const tasks = reviewFilteredTasks(baseTasks);
|
|
1088
|
+
const pageCount = Math.max(1, Math.ceil(tasks.length / taskPageSize));
|
|
1089
|
+
const page = Math.min(Math.max(1, Number(state.reviewQueuePage) || 1), pageCount);
|
|
1090
|
+
const visibleTasks = tasks.slice((page - 1) * taskPageSize, page * taskPageSize);
|
|
1091
|
+
return `<div class="dashboard-grid review-queue-page">
|
|
1092
|
+
<main class="dashboard-main stack">
|
|
1093
|
+
<section class="flow-panel">
|
|
1094
|
+
<div class="section-head">
|
|
1095
|
+
<div>
|
|
1096
|
+
<p class="eyebrow">${t("review")}</p>
|
|
1097
|
+
<h2>${t("reviewQueue")}</h2>
|
|
1098
|
+
<p class="subtle">${t("reviewQueueSubtitle")}</p>
|
|
1099
|
+
</div>
|
|
1100
|
+
<span class="subtle">${t("showing")} ${visibleTasks.length ? (page - 1) * taskPageSize + 1 : 0}-${Math.min(page * taskPageSize, tasks.length)} / ${tasks.length}</span>
|
|
1101
|
+
</div>
|
|
1102
|
+
<div class="review-queue-tabs" role="tablist" aria-label="${escapeAttr(t("reviewQueueTabs"))}">
|
|
1103
|
+
${tabs.map((tab) => reviewQueueTab(tab)).join("")}
|
|
1104
|
+
</div>
|
|
1105
|
+
<div class="review-queue-toolbar">
|
|
1106
|
+
<div class="input-group">
|
|
1107
|
+
<input data-search value="${escapeAttr(state.query)}" placeholder="${t("searchPlaceholder")}" aria-label="${t("searchTasks")}">
|
|
1108
|
+
</div>
|
|
1109
|
+
<div class="select-group">
|
|
1110
|
+
<label>${t("reasonFilter")}</label>
|
|
1111
|
+
<select data-review-reason-filter aria-label="${t("reasonFilter")}">
|
|
1112
|
+
<option value="all" ${state.reviewReasonFilter === "all" ? "selected" : ""}>${t("allReasons")}</option>
|
|
1113
|
+
${reasonOptions.map((code) => `<option value="${escapeAttr(code)}" ${state.reviewReasonFilter === code ? "selected" : ""}>${escapeHtml(code)}</option>`).join("")}
|
|
1114
|
+
</select>
|
|
1115
|
+
</div>
|
|
1116
|
+
<div class="select-group">
|
|
1117
|
+
<label>${t("sortBy")}</label>
|
|
1118
|
+
<select data-review-sort aria-label="${t("sortBy")}">
|
|
1119
|
+
${reviewSortOptions().map((option) => `<option value="${option.id}" ${state.reviewSort === option.id ? "selected" : ""}>${option.label}</option>`).join("")}
|
|
1120
|
+
</select>
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div class="review-queue-list-shell" tabindex="0" aria-label="${escapeAttr(activeTab.label)} ${escapeAttr(t("reviewQueue"))}">
|
|
1124
|
+
<div class="review-queue-list">
|
|
1125
|
+
${visibleTasks.map((task) => reviewQueueCard(task, activeTab)).join("") || emptyState(t("noQueueTasks"))}
|
|
1126
|
+
</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
<div class="review-queue-pager">
|
|
1129
|
+
${pager("review", page, pageCount)}
|
|
1130
|
+
</div>
|
|
1131
|
+
</section>
|
|
1132
|
+
</main>
|
|
1133
|
+
<aside class="dashboard-sidebar stack">
|
|
1134
|
+
<section class="side-panel review-queue-summary">
|
|
1135
|
+
<h3>${t("reviewQueue")}</h3>
|
|
1136
|
+
<div class="review-queue-stats">
|
|
1137
|
+
${tabs.map((tab) => metric(tab.label, reviewQueueBaseTasks(tab).length)).join("")}
|
|
1138
|
+
</div>
|
|
1139
|
+
</section>
|
|
1140
|
+
<section class="side-panel">
|
|
1141
|
+
<h3>${escapeHtml(activeTab.label)}</h3>
|
|
1142
|
+
<p>${escapeHtml(activeTab.description)}</p>
|
|
1143
|
+
<dl class="review-queue-contract">
|
|
1144
|
+
<div><dt>${t("reviewSubmitted")}</dt><dd>${reviewTruthyCount(baseTasks, "reviewSubmitted")}/${baseTasks.length}</dd></div>
|
|
1145
|
+
<div><dt>${t("materialsReady")}</dt><dd>${reviewTruthyCount(baseTasks, "materialsReady")}/${baseTasks.length}</dd></div>
|
|
1146
|
+
</dl>
|
|
1147
|
+
</section>
|
|
1148
|
+
</aside>
|
|
1149
|
+
</div>`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function ensureReviewQueueState() {
|
|
1153
|
+
if (!state.reviewQueueTab) state.reviewQueueTab = "review";
|
|
1154
|
+
if (!state.reviewReasonFilter) state.reviewReasonFilter = "all";
|
|
1155
|
+
if (!state.reviewSort) state.reviewSort = "queue";
|
|
1156
|
+
if (!state.reviewQueuePage) state.reviewQueuePage = 1;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function reviewQueueTabs() {
|
|
1160
|
+
return [
|
|
1161
|
+
{ id: "review", queues: ["review"], label: t("queueReview"), description: t("queueReviewDesc") },
|
|
1162
|
+
{ id: "missing-materials", queues: ["missing-materials"], label: t("queueMissingMaterials"), description: t("queueMissingMaterialsDesc"), repair: true },
|
|
1163
|
+
{ id: "blocked", queues: ["blocked"], label: t("queueBlocked"), description: t("queueBlockedDesc"), repair: true },
|
|
1164
|
+
{ id: "lessons", queues: ["lessons"], label: t("queueLessons"), description: t("queueLessonsDesc") },
|
|
1165
|
+
{ id: "confirmed-finalized", queues: ["confirmed", "finalized", "confirmed-finalized", "confirmed-finalization-pending"], label: t("queueConfirmedFinalized"), description: t("queueConfirmedFinalizedDesc") },
|
|
1166
|
+
{ id: "soft-deleted-superseded", queues: ["soft-deleted-superseded"], label: t("queueSoftDeletedSuperseded"), description: t("queueSoftDeletedSupersededDesc") },
|
|
1167
|
+
];
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function reviewQueueTab(tab) {
|
|
1171
|
+
const active = tab.id === state.reviewQueueTab;
|
|
1172
|
+
const count = reviewQueueBaseTasks(tab).length;
|
|
1173
|
+
return `<button type="button" class="review-queue-tab ${active ? "active" : ""}" data-review-queue-tab="${escapeAttr(tab.id)}" role="tab" aria-selected="${active ? "true" : "false"}">
|
|
1174
|
+
<span>${escapeHtml(tab.label)}</span>
|
|
1175
|
+
<strong>${count}</strong>
|
|
1176
|
+
</button>`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function reviewSortOptions() {
|
|
1180
|
+
return [
|
|
1181
|
+
{ id: "queue", label: t("sortQueuePriority") },
|
|
1182
|
+
{ id: "newest", label: t("sortNewest") },
|
|
1183
|
+
{ id: "oldest", label: t("sortOldest") },
|
|
1184
|
+
{ id: "id", label: t("sortTaskId") },
|
|
1185
|
+
];
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function reviewQueueBaseTasks(tab) {
|
|
1189
|
+
return (bundle.status?.tasks || []).filter((task) => taskMatchesReviewTab(task, tab));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function taskMatchesReviewTab(task, tab) {
|
|
1193
|
+
const queues = reviewTaskQueues(task);
|
|
1194
|
+
return (tab.queues || []).some((queue) => queues.includes(queue));
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function reviewTaskQueues(task) {
|
|
1198
|
+
return Array.isArray(task?.taskQueues) ? task.taskQueues : Array.isArray(task?.queues) ? task.queues : [];
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function reviewReasonOptions(tasks) {
|
|
1202
|
+
return [...new Set(tasks.flatMap((task) => (task.queueReasons || []).map((reason) => reason.code || reason.queue || "").filter(Boolean)))].sort();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function normalizeReviewReasonFilter(reasonOptions) {
|
|
1206
|
+
const current = state.reviewReasonFilter || "all";
|
|
1207
|
+
if (current === "all") return;
|
|
1208
|
+
if (!reasonOptions.includes(current)) state.reviewReasonFilter = "all";
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function reviewFilteredTasks(tasks) {
|
|
1212
|
+
const query = state.query.trim().toLowerCase();
|
|
1213
|
+
const reasonFilter = state.reviewReasonFilter || "all";
|
|
1214
|
+
return [...tasks]
|
|
1215
|
+
.filter((task) => {
|
|
1216
|
+
if (reasonFilter !== "all" && !(task.queueReasons || []).some((reason) => (reason.code || reason.queue) === reasonFilter)) return false;
|
|
1217
|
+
if (!query) return true;
|
|
1218
|
+
return [
|
|
1219
|
+
task.id,
|
|
1220
|
+
task.shortId,
|
|
1221
|
+
task.title,
|
|
1222
|
+
task.module,
|
|
1223
|
+
task.inferredModule,
|
|
1224
|
+
task.state,
|
|
1225
|
+
task.lifecycleState,
|
|
1226
|
+
task.reviewStatus,
|
|
1227
|
+
task.closeoutStatus,
|
|
1228
|
+
...(task.taskQueues || []),
|
|
1229
|
+
...(task.queueReasons || []).flatMap((reason) => [reason.code, reason.message, reason.sourcePath]),
|
|
1230
|
+
].some((value) => String(value || "").toLowerCase().includes(query));
|
|
1231
|
+
})
|
|
1232
|
+
.sort(reviewTaskSort);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function reviewTaskSort(left, right) {
|
|
1236
|
+
if (state.reviewSort === "newest") return compareTasksByTimeForOrder(left, right, "desc");
|
|
1237
|
+
if (state.reviewSort === "oldest") return compareTasksByTimeForOrder(left, right, "asc");
|
|
1238
|
+
if (state.reviewSort === "id") return stableTaskLabel(left).localeCompare(stableTaskLabel(right));
|
|
1239
|
+
return reviewPriorityRank(left) - reviewPriorityRank(right)
|
|
1240
|
+
|| compareTasksByTimeForOrder(left, right, "desc")
|
|
1241
|
+
|| stableTaskLabel(left).localeCompare(stableTaskLabel(right));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function compareTasksByTimeForOrder(left, right, order) {
|
|
1245
|
+
const previous = state.taskSortOrder;
|
|
1246
|
+
state.taskSortOrder = order;
|
|
1247
|
+
const result = compareTasksByTime(left, right);
|
|
1248
|
+
state.taskSortOrder = previous;
|
|
1249
|
+
return result;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function reviewPriorityRank(task) {
|
|
1253
|
+
const severityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
1254
|
+
const reasonRank = Math.min(...(task.queueReasons || []).map((reason) => severityRank[String(reason.severity || "").toUpperCase()] ?? 8), 8);
|
|
1255
|
+
const queueRank = { blocked: 0, "missing-materials": 1, review: 2, lessons: 3, confirmed: 4, finalized: 5, "soft-deleted-superseded": 6 };
|
|
1256
|
+
const queues = reviewTaskQueues(task);
|
|
1257
|
+
const taskQueueRank = Math.min(...queues.map((queue) => queueRank[queue] ?? 7), 7);
|
|
1258
|
+
return Math.min(reasonRank, taskQueueRank);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function reviewTruthyCount(tasks, key) {
|
|
1262
|
+
return tasks.filter((task) => task[key] === true).length;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function reviewQueueCard(task, tab) {
|
|
1266
|
+
const openMaterial = (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
|
|
1267
|
+
const reasons = task.queueReasons || [];
|
|
1268
|
+
const canCopyRepairPrompt = tab?.repair && String(task.repairPrompt || "").trim();
|
|
1269
|
+
const lessonActions = tab?.id === "lessons" ? lessonCandidatePanel(task, { context: "card", limit: 2 }) : "";
|
|
1270
|
+
const displayId = task.shortId || taskFolderName(task) || task.id;
|
|
1271
|
+
return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
1272
|
+
<div class="card-header">
|
|
1273
|
+
<span class="card-id" title="${escapeAttr(task.id)}">${escapeHtml(displayId)}</span>
|
|
1274
|
+
${tag(task.reviewStatus || "missing")}
|
|
1275
|
+
${reviewTaskQueues(task).map(tag).join("")}
|
|
1276
|
+
</div>
|
|
1277
|
+
<h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
|
|
1278
|
+
<div class="card-meta">
|
|
1279
|
+
<span>${tag(task.lifecycleState || "unknown")}</span>
|
|
1280
|
+
<span>${tag(task.closeoutStatus || "missing")}</span>
|
|
1281
|
+
<span>${openMaterial} ${t("openFindings")}</span>
|
|
1282
|
+
<span>${t("reviewSubmitted")}: ${task.reviewSubmitted === true ? t("yes") : t("no")}</span>
|
|
1283
|
+
<span>${t("materialsReady")}: ${task.materialsReady === true ? t("yes") : t("no")}</span>
|
|
1284
|
+
</div>
|
|
1285
|
+
<p class="subtle">${escapeHtml(firstUsefulLine(task.summary || task.briefText || ""))}</p>
|
|
1286
|
+
${reasons.length ? `<div class="review-reasons">${reasons.slice(0, 4).map(reviewReason).join("")}</div>` : ""}
|
|
1287
|
+
${lessonActions}
|
|
1288
|
+
<div class="review-queue-actions">
|
|
1289
|
+
<a href="#/review/${encodeURIComponent(task.id)}">${t("openReviewWorkspace")}</a>
|
|
1290
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}">${t("fullView")}</a>
|
|
1291
|
+
<button data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
|
|
1292
|
+
${tab?.repair ? `<button data-copy-repair-prompt="${escapeAttr(task.id)}" data-repair-prompt="${escapeAttr(task.repairPrompt || "")}" ${canCopyRepairPrompt ? "" : "disabled"}>${t("copyRepairPrompt")}</button>` : ""}
|
|
1293
|
+
</div>
|
|
1294
|
+
</article>`;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function lessonCandidatePanel(task, { context = "detail", limit = 0 } = {}) {
|
|
1298
|
+
const candidates = (task.lessonCandidateRows || []).filter((candidate) => ["ready-for-review", "needs-promotion"].includes(candidate.status));
|
|
1299
|
+
if (!candidates.length) return "";
|
|
1300
|
+
const visibleCandidates = limit > 0 ? candidates.slice(0, limit) : candidates;
|
|
1301
|
+
const hiddenCount = Math.max(0, candidates.length - visibleCandidates.length);
|
|
1302
|
+
const staticNote = canUseWorkbenchAction("lesson-sedimentation-task") ? "" : `<p class="lesson-action-note">${escapeHtml(t("lessonWorkbenchRequired"))}</p>`;
|
|
1303
|
+
return `<section class="lesson-candidate-panel ${context === "card" ? "compact" : ""}">
|
|
1304
|
+
<div class="lesson-candidate-panel-head">
|
|
1305
|
+
<div>
|
|
1306
|
+
<p class="eyebrow">${t("lessonCandidates")}</p>
|
|
1307
|
+
<h3>${t("lessonSedimentationActions")}</h3>
|
|
1308
|
+
</div>
|
|
1309
|
+
<span class="tag">${visibleCandidates.length}/${candidates.length}</span>
|
|
1310
|
+
</div>
|
|
1311
|
+
${staticNote}
|
|
1312
|
+
<div class="lesson-candidate-actions">
|
|
1313
|
+
${visibleCandidates.map((candidate) => lessonCandidateAction(task, candidate)).join("")}
|
|
1314
|
+
</div>
|
|
1315
|
+
${hiddenCount ? `<a class="lesson-candidate-more" href="#/review/${encodeURIComponent(task.id)}">${escapeHtml(t("moreLessonCandidates")).replace("{count}", String(hiddenCount))}</a>` : ""}
|
|
297
1316
|
</section>`;
|
|
298
1317
|
}
|
|
299
1318
|
|
|
300
|
-
function
|
|
301
|
-
|
|
1319
|
+
function lessonCandidateAction(task, candidate) {
|
|
1320
|
+
const followUp = String(candidate.followUpTask || "").trim();
|
|
1321
|
+
const hasFollowUp = followUp && !/^pending$/i.test(followUp);
|
|
1322
|
+
const prompt = lessonSedimentationPrompt(task, candidate);
|
|
1323
|
+
return `<div class="lesson-candidate-action">
|
|
1324
|
+
<div class="lesson-candidate-main">
|
|
1325
|
+
<strong>${escapeHtml(candidate.id)}</strong>
|
|
1326
|
+
<span>${escapeHtml(candidate.title || candidate.promotionTarget || t("lessonCandidates"))}</span>
|
|
1327
|
+
<small>${escapeHtml(candidate.scope || t("none"))} · ${escapeHtml(candidate.promotionTarget || t("none"))}</small>
|
|
1328
|
+
</div>
|
|
1329
|
+
<span class="review-result" data-lesson-result="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}"></span>
|
|
1330
|
+
<div class="lesson-candidate-command-row">
|
|
1331
|
+
${hasFollowUp ? `<a href="#/tasks/${encodeURIComponent(followUp)}">${t("openFollowUpTask")}</a>` : ""}
|
|
1332
|
+
<button data-copy-lesson-prompt="${escapeAttr(task.id)}:${escapeAttr(candidate.id)}" data-lesson-prompt="${escapeAttr(prompt)}">${t("copyLessonPrompt")}</button>
|
|
1333
|
+
<button data-create-lesson-sedimentation="${escapeAttr(task.id)}" data-candidate-id="${escapeAttr(candidate.id)}" ${canUseWorkbenchAction("lesson-sedimentation-task") && !hasFollowUp ? "" : "disabled"}>${t("createLessonTask")}</button>
|
|
1334
|
+
</div>
|
|
1335
|
+
</div>`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function lessonSedimentationPrompt(task, candidate) {
|
|
1339
|
+
return [
|
|
1340
|
+
"You are executing a lesson sedimentation follow-up task.",
|
|
1341
|
+
"",
|
|
1342
|
+
`Source task: ${task.id}`,
|
|
1343
|
+
`Source candidate: ${candidate.id} - ${candidate.title || ""}`,
|
|
1344
|
+
`Candidate scope: ${candidate.scope || "unspecified"}`,
|
|
1345
|
+
`Candidate module key: ${candidate.moduleKey || "n/a"}`,
|
|
1346
|
+
`Detail artifact: ${candidate.detailArtifact || "not provided"}`,
|
|
1347
|
+
`Boundary reason: ${candidate.boundaryReason || "unspecified"}`,
|
|
1348
|
+
`Why it might matter: ${candidate.whyItMightMatter || "unspecified"}`,
|
|
1349
|
+
`Promotion target: ${candidate.promotionTarget || "unspecified"}`,
|
|
1350
|
+
`Conflict check: ${candidate.conflictCheck || "pending"}`,
|
|
1351
|
+
`Required standard update: ${candidate.requiredStandardUpdate || "pending"}`,
|
|
1352
|
+
"",
|
|
1353
|
+
"Instructions:",
|
|
1354
|
+
"1. Read the source task, review, findings, progress, lesson_candidates.md, and the task-local detail artifact.",
|
|
1355
|
+
"2. Use the detail artifact as the lesson body source; do not reconstruct the lesson from the brief row.",
|
|
1356
|
+
"3. Classify whether the lesson is task-local, module-local, or global, preserving the module key and source path when present.",
|
|
1357
|
+
"4. Check conflicts against existing lessons and standards.",
|
|
1358
|
+
"5. Propose the smallest diff first.",
|
|
1359
|
+
"6. Do not write a shared Lessons table; use task-local candidates and promoted detail docs.",
|
|
1360
|
+
].join("\n");
|
|
302
1361
|
}
|
|
303
1362
|
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1363
|
+
function reviewReason(reason) {
|
|
1364
|
+
return `<div class="review-reason">
|
|
1365
|
+
<strong>${escapeHtml(reason.code || reason.queue || t("reason"))}</strong>
|
|
1366
|
+
<span>${escapeHtml(reason.message || reason.sourcePath || "")}</span>
|
|
1367
|
+
</div>`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function firstUsefulLine(text) {
|
|
1371
|
+
return String(text || "")
|
|
1372
|
+
.split(/\n+/)
|
|
1373
|
+
.map((line) => line.trim())
|
|
1374
|
+
.filter(Boolean)[0] || "";
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function reviewWorkspace(route) {
|
|
1378
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === route.id);
|
|
1379
|
+
if (!task) return `<main>${emptyState(t("taskNotFound"))}</main>`;
|
|
1380
|
+
const walkthroughDoc = taskDocument(task, "__walkthrough__");
|
|
1381
|
+
const candidateDoc = taskDocument(task, "lesson_candidates.md");
|
|
1382
|
+
const reviewDoc = taskDocument(task, "review.md");
|
|
1383
|
+
const findingsDoc = taskDocument(task, "findings.md");
|
|
1384
|
+
return `<main class="review-workspace">
|
|
1385
|
+
<nav class="crumbs"><a href="#/review">${t("reviewQueue")}</a><span>/</span><span>${escapeHtml(task.id)}</span></nav>
|
|
1386
|
+
<section class="detail-hero review-hero">
|
|
1387
|
+
<div>
|
|
1388
|
+
<p class="eyebrow">${t("reviewWorkspace")}</p>
|
|
1389
|
+
<h2>${escapeHtml(task.title)}</h2>
|
|
1390
|
+
<p>${escapeHtml(task.path)}</p>
|
|
1391
|
+
</div>
|
|
1392
|
+
<div class="review-hero-tags">
|
|
1393
|
+
${tag(task.lifecycleState || "unknown")}
|
|
1394
|
+
${tag(task.reviewStatus || "missing")}
|
|
1395
|
+
${tag(task.lessonCandidateStatus || "missing")}
|
|
1396
|
+
</div>
|
|
1397
|
+
</section>
|
|
1398
|
+
<section class="review-workspace-grid">
|
|
1399
|
+
<article class="review-workspace-main stack">
|
|
1400
|
+
${reviewDocPanel("walkthrough", walkthroughDoc, task.walkthroughPath)}
|
|
1401
|
+
${reviewDocPanel("lessonCandidates", candidateDoc, task.lessonCandidatePath)}
|
|
1402
|
+
${reviewDocPanel("review", reviewDoc, task.reviewPath)}
|
|
1403
|
+
${reviewDocPanel("findings", findingsDoc, task.findingsPath)}
|
|
1404
|
+
</article>
|
|
1405
|
+
<aside class="review-workspace-side stack">
|
|
1406
|
+
${reviewActionPanel(task, { mode: "workspace" })}
|
|
1407
|
+
${taskStateSummary(task)}
|
|
1408
|
+
${openFindings(task)}
|
|
1409
|
+
${evidenceList(task)}
|
|
1410
|
+
</aside>
|
|
1411
|
+
</section>
|
|
1412
|
+
</main>`;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function reviewDocPanel(key, doc, fallbackPath = "") {
|
|
1416
|
+
return `<section class="doc-section review-doc-panel">
|
|
1417
|
+
<div class="section-head">
|
|
1418
|
+
<div>
|
|
1419
|
+
<p class="eyebrow">${escapeHtml(fallbackPath || "")}</p>
|
|
1420
|
+
<h2>${t(key)}</h2>
|
|
1421
|
+
</div>
|
|
1422
|
+
${doc ? `<button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button>` : ""}
|
|
1423
|
+
</div>
|
|
1424
|
+
<div class="review-doc-scroll"><div class="markdown">${doc ? window.HarnessMarkdown.render(doc.content, state.renderMode) : emptyState(t("documentMissing"))}</div></div>
|
|
1425
|
+
</section>`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function migrationPanel() {
|
|
1429
|
+
const advice = warningQueue();
|
|
1430
|
+
const missingBriefs = advice.filter((warning) => warning.type === "missing-brief").length;
|
|
1431
|
+
if (advice.length === 0 && missingBriefs === 0) return "";
|
|
1432
|
+
const groups = groupBy(advice, (item) => item.category || "Advice");
|
|
1433
|
+
const categories = Object.entries(groups).slice(0, 6);
|
|
1434
|
+
return `<section class="migration-panel">
|
|
1435
|
+
<div class="section-head">
|
|
1436
|
+
<div>
|
|
1437
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1438
|
+
<h2>${t("migrationWorkbench")}</h2>
|
|
1439
|
+
</div>
|
|
1440
|
+
<span>${advice.length} ${t("advice")} · ${missingBriefs} ${t("briefMissing")}</span>
|
|
1441
|
+
</div>
|
|
1442
|
+
<div class="migration-grid">
|
|
1443
|
+
${categories.map(([category, items]) => `<button data-warning-filter="${escapeAttr(category)}" class="${state.warningFilter === category ? "active" : ""}"><strong>${escapeHtml(category)}</strong><p>${items.length} ${t("items")}</p></button>`).join("")}
|
|
1444
|
+
${missingBriefs > 0 ? `<div><strong>${t("visibilityLayer")}</strong><p>${missingBriefs} ${t("missingBriefs")}</p></div>` : ""}
|
|
1445
|
+
</div>
|
|
1446
|
+
${migrationWarningWorkbench(advice)}
|
|
1447
|
+
</section>`;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function migrationWarningWorkbench(advice) {
|
|
1451
|
+
const groups = groupBy(advice, (item) => item.category || "Advice");
|
|
1452
|
+
const filters = ["all", ...Object.keys(groups).sort(), ...new Set(advice.map((item) => item.type).filter(Boolean)), "active-task-contracts", "strict-cutover"];
|
|
1453
|
+
const filtered = state.warningFilter === "all" ? advice : advice.filter((item) => (item.category || "Advice") === state.warningFilter || item.phase === state.warningFilter || item.type === state.warningFilter);
|
|
1454
|
+
const pageCount = Math.max(1, Math.ceil(filtered.length / warningPageSize));
|
|
1455
|
+
const page = Math.min(Math.max(1, Number(state.warningPage) || 1), pageCount);
|
|
1456
|
+
const visible = filtered.slice((page - 1) * warningPageSize, page * warningPageSize);
|
|
1457
|
+
return `<div class="warning-workbench">
|
|
1458
|
+
<div class="warning-toolbar">
|
|
1459
|
+
<select data-warning-filter-select aria-label="${t("warningFilter")}">
|
|
1460
|
+
${filters.map((filter) => `<option value="${escapeAttr(filter)}" ${state.warningFilter === filter ? "selected" : ""}>${filter === "all" ? t("allWarnings") : escapeHtml(filter)}</option>`).join("")}
|
|
1461
|
+
</select>
|
|
1462
|
+
<span>${t("showing")} ${visible.length ? (page - 1) * warningPageSize + 1 : 0}-${Math.min(page * warningPageSize, filtered.length)} / ${filtered.length}</span>
|
|
1463
|
+
${pager("warning", page, pageCount)}
|
|
1464
|
+
</div>
|
|
1465
|
+
<div class="warning-list">
|
|
1466
|
+
${visible.map(warningRow).join("") || emptyState(t("noWarnings"))}
|
|
1467
|
+
</div>
|
|
1468
|
+
</div>`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function migrationSummaryPanel() {
|
|
1472
|
+
const advice = warningQueue();
|
|
1473
|
+
const summary = bundle.status?.summary || {};
|
|
1474
|
+
if (advice.length === 0 && summary.fullCutoverEligible) {
|
|
1475
|
+
return `<section class="migration-panel">
|
|
1476
|
+
<div class="section-head">
|
|
1477
|
+
<div>
|
|
1478
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1479
|
+
<h2>${t("fullCutover")}</h2>
|
|
1480
|
+
</div>
|
|
1481
|
+
<span>${t("ready")}</span>
|
|
1482
|
+
</div>
|
|
1483
|
+
${emptyState(t("noWarnings"))}
|
|
1484
|
+
</section>`;
|
|
308
1485
|
}
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1486
|
+
const cards = [
|
|
1487
|
+
[t("advice"), advice.length],
|
|
1488
|
+
[t("legacyVisualOnly"), summary.legacyVisualOnlyCount || 0],
|
|
1489
|
+
[t("weakBrief"), summary.weakBriefCount || 0],
|
|
1490
|
+
[t("blockers"), bundle.status?.checkState?.failures || 0],
|
|
1491
|
+
];
|
|
1492
|
+
return `<section class="migration-panel">
|
|
1493
|
+
<div class="section-head">
|
|
1494
|
+
<div>
|
|
1495
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1496
|
+
<h2>${t("migrationSummary")}</h2>
|
|
1497
|
+
</div>
|
|
1498
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
1499
|
+
</div>
|
|
1500
|
+
<div class="migration-grid">
|
|
1501
|
+
${cards.map(([title, count]) => `<a href="#/tasks"><strong>${escapeHtml(title)}</strong><p>${count} ${t("items")}</p></a>`).join("")}
|
|
1502
|
+
</div>
|
|
1503
|
+
${migrationWarningWorkbench(advice)}
|
|
1504
|
+
</section>`;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function warningRow(warning) {
|
|
1508
|
+
const affected = warning.affectedPaths?.length ? warning.affectedPaths.join(", ") : warning.affected;
|
|
1509
|
+
return `<article class="warning-row">
|
|
1510
|
+
<div>
|
|
1511
|
+
<strong>${escapeHtml(warning.id)} · ${escapeHtml(warning.title)}</strong>
|
|
1512
|
+
<p>${escapeHtml(affected || "project")}</p>
|
|
1513
|
+
</div>
|
|
1514
|
+
<span>${tag(warning.priority || warning.severity)}</span>
|
|
1515
|
+
<span>${escapeHtml(warning.status || "open")}</span>
|
|
1516
|
+
<span>${escapeHtml(warning.fixability || "manual")}</span>
|
|
1517
|
+
<span>${escapeHtml(warning.phase || "triage")}</span>
|
|
1518
|
+
<p>${escapeHtml(warning.requiredAction || warning.detail || "")} · ${t("confidence")}: ${escapeHtml(warning.confidence || "medium")}</p>
|
|
1519
|
+
</article>`;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function warningQueue() {
|
|
1523
|
+
const adoptionWarnings = (bundle.adoption?.warnings || []).map((warning) => ({ ...warning }));
|
|
1524
|
+
const existingBriefPaths = new Set(adoptionWarnings.filter((warning) => warning.type === "missing-brief").map((warning) => warning.affected));
|
|
1525
|
+
const briefWarnings = (bundle.status?.tasks || [])
|
|
1526
|
+
.filter((task) => task.briefSource !== "standalone")
|
|
1527
|
+
.filter((task) => !existingBriefPaths.has(task.path))
|
|
1528
|
+
.map((task, index) => ({
|
|
1529
|
+
id: `VB-${String(index + 1).padStart(3, "0")}`,
|
|
1530
|
+
category: "Visibility Layer",
|
|
1531
|
+
type: "missing-brief",
|
|
1532
|
+
scope: "task",
|
|
1533
|
+
priority: (typeof isActiveTaskState === "function" && isActiveTaskState(task.state)) || ["planned", "not_started"].includes(task.state) ? "P2" : "P3",
|
|
1534
|
+
phase: "active-task-contracts",
|
|
1535
|
+
fixability: "guided",
|
|
1536
|
+
status: "open",
|
|
1537
|
+
confidence: task.state === "unknown" ? "medium" : "high",
|
|
1538
|
+
severity: "advice",
|
|
1539
|
+
title: t("visibilityBriefMissing"),
|
|
1540
|
+
affected: task.path,
|
|
1541
|
+
affectedPaths: [task.path],
|
|
1542
|
+
requiredAction: t("addVisibilityBrief"),
|
|
1543
|
+
detail: `${task.id} ${task.title}`,
|
|
1544
|
+
}));
|
|
1545
|
+
return [...adoptionWarnings, ...briefWarnings].sort(warningSort);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function warningSort(left, right) {
|
|
1549
|
+
const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
1550
|
+
const fixRank = { template: 0, guided: 1, "human-evidence": 2, decision: 3, manual: 4 };
|
|
1551
|
+
return (priorityRank[left.priority] ?? 9) - (priorityRank[right.priority] ?? 9)
|
|
1552
|
+
|| (fixRank[left.fixability] ?? 9) - (fixRank[right.fixability] ?? 9)
|
|
1553
|
+
|| String(left.phase || "").localeCompare(String(right.phase || ""))
|
|
1554
|
+
|| String(left.id || "").localeCompare(String(right.id || ""));
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function pager(kind, page, pageCount, group = "") {
|
|
1558
|
+
if (pageCount <= 1) return `<span class="pager muted">${page}/${pageCount}</span>`;
|
|
1559
|
+
const groupAttr = group ? ` data-page-group="${escapeAttr(group)}"` : "";
|
|
1560
|
+
return `<div class="pager">
|
|
1561
|
+
<button data-page-kind="${kind}" data-page="${page - 1}"${groupAttr} ${page <= 1 ? "disabled" : ""}>${t("prevPage")}</button>
|
|
1562
|
+
<span>${page}/${pageCount}</span>
|
|
1563
|
+
<button data-page-kind="${kind}" data-page="${page + 1}"${groupAttr} ${page >= pageCount ? "disabled" : ""}>${t("nextPage")}</button>
|
|
1564
|
+
</div>`;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function lessonPanel() {
|
|
1568
|
+
const lessons = lessonDocuments();
|
|
1569
|
+
return `<section class="lesson-panel">
|
|
1570
|
+
<div class="section-head"><h2>${t("lessons")}</h2><span>${lessons.length}</span></div>
|
|
1571
|
+
<div class="lesson-list" style="padding-top: 10px;">
|
|
1572
|
+
${lessons.map((lesson) => {
|
|
1573
|
+
return `<div class="lesson" data-open-lesson-drawer="${escapeAttr(lesson.id)}">
|
|
1574
|
+
<strong>${escapeHtml(lesson.id)}</strong>
|
|
1575
|
+
<p>${escapeHtml(lesson.title || lesson.path)}</p>
|
|
1576
|
+
</div>`;
|
|
1577
|
+
}).join("") || emptyState(t("noLessons"))}
|
|
1578
|
+
</div>
|
|
1579
|
+
</section>`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function lessonDocuments() {
|
|
1583
|
+
return (bundle.documents?.documents || [])
|
|
1584
|
+
.filter((doc) => doc.type === "lesson-detail" || /\/01-GOVERNANCE\/lessons\/[^/]+\.md$/i.test(doc.path || ""))
|
|
1585
|
+
.map((doc) => {
|
|
1586
|
+
const id = lessonIdFromDocument(doc);
|
|
1587
|
+
return { id, title: (doc.title || "").replace(new RegExp(`^${id}\\s*-\\s*`, "i"), ""), path: doc.path, doc };
|
|
1588
|
+
})
|
|
1589
|
+
.filter((lesson) => lesson.id)
|
|
1590
|
+
.sort((left, right) => String(right.id).localeCompare(String(left.id)));
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function lessonIdFromDocument(doc) {
|
|
1594
|
+
const content = doc?.content || "";
|
|
1595
|
+
const path = doc?.path || "";
|
|
1596
|
+
return content.match(/#\s*(L-\d{4}(?:-\d{2}-\d{2})?-\d+)/i)?.[1]
|
|
1597
|
+
|| path.match(/(L-\d{4}(?:-\d{2}-\d{2})?-\d+)/i)?.[1]
|
|
1598
|
+
|| "";
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function healthPanel() {
|
|
1602
|
+
const details = bundle.status?.checkState?.details || { failures: [], warnings: [] };
|
|
1603
|
+
return `<section class="health-panel">
|
|
1604
|
+
<div><h2>${t("releaseHealth")}</h2><p>${escapeHtml(bundle.status?.mode || "unknown")} · schema ${escapeHtml(bundle.status?.schemaVersion || "n/a")}</p></div>
|
|
1605
|
+
<div class="health-lists">
|
|
1606
|
+
<details ${details.failures?.length ? "open" : ""}><summary>${t("failures")} (${details.failures?.length || 0})</summary>${list(details.failures)}</details>
|
|
1607
|
+
<details><summary>${t("warnings")} (${details.warnings?.length || 0})</summary>${list(details.warnings?.slice(0, 40))}</details>
|
|
1608
|
+
</div>
|
|
1609
|
+
</section>`;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function taskDocument(task, fileName) {
|
|
1613
|
+
if (fileName === "__walkthrough__" && task.walkthroughPath) return findDocument(task.walkthroughPath);
|
|
1614
|
+
return findDocument(`${task.path}/${fileName}`);
|
|
327
1615
|
}
|
|
328
1616
|
|
|
329
1617
|
function findDocument(pathSuffix) {
|
|
330
|
-
return (bundle.documents?.documents || []).find((doc) => doc.path.endsWith(pathSuffix));
|
|
1618
|
+
return (bundle.documents?.documents || []).find((doc) => doc.path.endsWith(pathSuffix) || doc.path === pathSuffix);
|
|
331
1619
|
}
|
|
332
1620
|
|
|
333
|
-
function
|
|
334
|
-
|
|
1621
|
+
function mermaidLabel(id) {
|
|
1622
|
+
const node = (bundle.graph?.nodes || []).find((item) => item.id === id);
|
|
1623
|
+
return String(node?.label || id).replaceAll('"', "'").slice(0, 48);
|
|
335
1624
|
}
|
|
336
1625
|
|
|
337
|
-
function
|
|
1626
|
+
function mermaidId(value) {
|
|
1627
|
+
return `N_${String(value).replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function progressBar(value) {
|
|
1631
|
+
const score = Math.max(0, Math.min(100, Number(value) || 0));
|
|
1632
|
+
return `<div class="progress" aria-label="${score}%"><i style="width:${score}%"></i></div>`;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function tag(value) {
|
|
338
1636
|
const raw = String(value || "unknown");
|
|
339
|
-
const klass = /fail|blocked|open/i.test(raw) ? "fail" : /warn|advice|planned|missing/i.test(raw) ? "warn" : /pass|done|present|verified/i.test(raw) ? "pass" : "";
|
|
340
|
-
return `<span class="tag ${klass}">${escapeHtml(
|
|
1637
|
+
const klass = /fail|blocked|open/i.test(raw) ? "fail" : /warn|advice|planned|missing|unknown/i.test(raw) ? "warn" : /pass|done|present|verified|review|in_progress/i.test(raw) ? "pass" : "";
|
|
1638
|
+
return `<span class="tag ${klass}">${escapeHtml(label(raw))}</span>`;
|
|
341
1639
|
}
|
|
342
1640
|
|
|
343
1641
|
function label(value) {
|
|
344
|
-
|
|
345
|
-
pass: "pass",
|
|
346
|
-
warn: "warn",
|
|
347
|
-
fail: "fail",
|
|
348
|
-
in_progress: "in progress",
|
|
349
|
-
planned: "planned",
|
|
350
|
-
done: "done",
|
|
351
|
-
blocked: "blocked",
|
|
352
|
-
missing: "missing",
|
|
353
|
-
present: "present",
|
|
354
|
-
closed: "closed",
|
|
355
|
-
advice: "advice",
|
|
356
|
-
standalone: "standalone",
|
|
357
|
-
legacy: "legacy",
|
|
358
|
-
"task-review": "review",
|
|
359
|
-
"task-progress": "progress",
|
|
360
|
-
"regression-ssot": "regression",
|
|
361
|
-
"cadence-ledger": "cadence",
|
|
362
|
-
unknown: "unknown",
|
|
363
|
-
};
|
|
364
|
-
return labels[value] || value;
|
|
1642
|
+
return t(`state_${value}`) || String(value || "unknown").replaceAll("_", " ");
|
|
365
1643
|
}
|
|
366
1644
|
|
|
367
|
-
function
|
|
368
|
-
|
|
369
|
-
return `<span>${score}%</span><div class="bar"><i style="width:${score}%"></i></div>`;
|
|
1645
|
+
function list(items = []) {
|
|
1646
|
+
return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("") || `<li>${t("none")}</li>`}</ul>`;
|
|
370
1647
|
}
|
|
371
1648
|
|
|
372
|
-
function
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
1649
|
+
function emptyState(text) {
|
|
1650
|
+
return `<div class="empty">${escapeHtml(text)}</div>`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function projectName() {
|
|
1654
|
+
return bundle.status?.project?.name || "Harness";
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function themeLabel() {
|
|
1658
|
+
return state.theme === "dark" ? t("light") : state.theme === "light" ? t("system") : t("dark");
|
|
381
1659
|
}
|
|
382
1660
|
|
|
383
1661
|
function groupBy(items, fn) {
|
|
@@ -389,35 +1667,517 @@ function groupBy(items, fn) {
|
|
|
389
1667
|
}, {});
|
|
390
1668
|
}
|
|
391
1669
|
|
|
1670
|
+
function canUseWorkbenchAction(action) {
|
|
1671
|
+
return state.runtime?.mode === "workbench" && (state.runtime?.writableActions || []).includes(action);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
window.setModulePage = function(moduleKey, page) {
|
|
1675
|
+
state.modulePages = state.modulePages || {};
|
|
1676
|
+
state.modulePages[moduleKey] = page;
|
|
1677
|
+
app();
|
|
1678
|
+
};
|
|
1679
|
+
|
|
392
1680
|
function bind() {
|
|
393
|
-
document.querySelectorAll("[data-
|
|
394
|
-
state.
|
|
395
|
-
state.
|
|
1681
|
+
document.querySelectorAll("[data-search]").forEach((input) => input.addEventListener("input", () => {
|
|
1682
|
+
state.query = input.value;
|
|
1683
|
+
state.taskPageByGroup = {};
|
|
1684
|
+
state.taskGroupPage = 1;
|
|
396
1685
|
app();
|
|
397
1686
|
}));
|
|
398
|
-
document.querySelectorAll("[data-
|
|
399
|
-
state.
|
|
400
|
-
|
|
1687
|
+
document.querySelectorAll("[data-state-filter]").forEach((select) => select.addEventListener("change", () => {
|
|
1688
|
+
state.taskState = select.value;
|
|
1689
|
+
state.taskPageByGroup = {};
|
|
1690
|
+
state.taskGroupPage = 1;
|
|
1691
|
+
app();
|
|
1692
|
+
}));
|
|
1693
|
+
document.querySelectorAll("[data-group-mode]").forEach((select) => select.addEventListener("change", () => {
|
|
1694
|
+
state.taskGroupMode = select.value;
|
|
1695
|
+
state.taskPageByGroup = {};
|
|
1696
|
+
state.taskGroupPage = 1;
|
|
1697
|
+
app();
|
|
1698
|
+
}));
|
|
1699
|
+
document.querySelectorAll("[data-layout]").forEach((btn) => btn.addEventListener("click", () => {
|
|
1700
|
+
state.taskLayout = btn.dataset.layout;
|
|
1701
|
+
localStorage.setItem("harness.taskLayout", state.taskLayout);
|
|
1702
|
+
app();
|
|
1703
|
+
}));
|
|
1704
|
+
document.querySelectorAll("[data-task-sort-order]").forEach((btn) => btn.addEventListener("click", () => {
|
|
1705
|
+
state.taskSortOrder = btn.dataset.taskSortOrder === "asc" ? "asc" : "desc";
|
|
1706
|
+
localStorage.setItem("harness.taskSortOrder", state.taskSortOrder);
|
|
1707
|
+
state.taskPageByGroup = {};
|
|
1708
|
+
state.taskGroupPage = 1;
|
|
401
1709
|
app();
|
|
402
1710
|
}));
|
|
403
|
-
document.querySelectorAll("[data-
|
|
404
|
-
state.
|
|
405
|
-
localStorage.setItem("harness.density", state.density);
|
|
1711
|
+
document.querySelectorAll("[data-render-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1712
|
+
state.renderMode = state.renderMode === "rendered" ? "source" : "rendered";
|
|
406
1713
|
app();
|
|
407
1714
|
}));
|
|
408
|
-
document.querySelectorAll("[data-
|
|
409
|
-
state.
|
|
410
|
-
state.
|
|
1715
|
+
document.querySelectorAll("[data-warning-filter]").forEach((button) => button.addEventListener("click", () => {
|
|
1716
|
+
state.warningFilter = button.dataset.warningFilter || "all";
|
|
1717
|
+
state.warningPage = 1;
|
|
411
1718
|
app();
|
|
412
1719
|
}));
|
|
413
|
-
document.querySelectorAll("[data-
|
|
414
|
-
state.
|
|
1720
|
+
document.querySelectorAll("[data-warning-filter-select]").forEach((select) => select.addEventListener("change", () => {
|
|
1721
|
+
state.warningFilter = select.value;
|
|
1722
|
+
state.warningPage = 1;
|
|
415
1723
|
app();
|
|
416
1724
|
}));
|
|
417
|
-
document.querySelectorAll("[data-
|
|
418
|
-
state.
|
|
1725
|
+
document.querySelectorAll("[data-review-queue-tab]").forEach((button) => button.addEventListener("click", () => {
|
|
1726
|
+
state.reviewQueueTab = button.dataset.reviewQueueTab || "review";
|
|
1727
|
+
state.reviewQueuePage = 1;
|
|
419
1728
|
app();
|
|
420
1729
|
}));
|
|
1730
|
+
document.querySelectorAll("[data-review-reason-filter]").forEach((select) => select.addEventListener("change", () => {
|
|
1731
|
+
state.reviewReasonFilter = select.value || "all";
|
|
1732
|
+
state.reviewQueuePage = 1;
|
|
1733
|
+
app();
|
|
1734
|
+
}));
|
|
1735
|
+
document.querySelectorAll("[data-review-sort]").forEach((select) => select.addEventListener("change", () => {
|
|
1736
|
+
state.reviewSort = select.value || "queue";
|
|
1737
|
+
state.reviewQueuePage = 1;
|
|
1738
|
+
app();
|
|
1739
|
+
}));
|
|
1740
|
+
document.querySelectorAll("[data-page-kind]").forEach((button) => button.addEventListener("click", () => {
|
|
1741
|
+
const page = Math.max(1, Number(button.dataset.page) || 1);
|
|
1742
|
+
if (button.dataset.pageKind === "warning") state.warningPage = page;
|
|
1743
|
+
if (button.dataset.pageKind === "task-groups") state.taskGroupPage = page;
|
|
1744
|
+
if (button.dataset.pageKind === "task") state.taskPageByGroup[button.dataset.pageGroup || ""] = page;
|
|
1745
|
+
if (button.dataset.pageKind === "review") state.reviewQueuePage = page;
|
|
1746
|
+
app();
|
|
1747
|
+
}));
|
|
1748
|
+
document.querySelectorAll("[data-runway-phase]").forEach((link) => link.addEventListener("click", () => {
|
|
1749
|
+
const phase = link.dataset.runwayPhase || "all";
|
|
1750
|
+
if (phase === "module-classification") state.taskGroupMode = "module";
|
|
1751
|
+
if (["triage", "active-task-contracts", "strict-cutover"].includes(phase)) state.warningFilter = phase === "triage" ? "all" : phase;
|
|
1752
|
+
state.warningPage = 1;
|
|
1753
|
+
state.taskGroupPage = 1;
|
|
1754
|
+
if (link.getAttribute("href") === "#/") app();
|
|
1755
|
+
}));
|
|
1756
|
+
document.querySelectorAll("[data-theme-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1757
|
+
state.theme = state.theme === "dark" ? "light" : state.theme === "light" ? "system" : "dark";
|
|
1758
|
+
localStorage.setItem("harness.theme", state.theme);
|
|
1759
|
+
app();
|
|
1760
|
+
}));
|
|
1761
|
+
document.querySelectorAll("[data-language-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1762
|
+
setLocale(locale === "zh" ? "en" : "zh");
|
|
1763
|
+
app();
|
|
1764
|
+
}));
|
|
1765
|
+
document.querySelectorAll("[data-open-drawer]").forEach((el) => el.addEventListener("click", (e) => {
|
|
1766
|
+
e.preventDefault();
|
|
1767
|
+
const taskId = el.dataset.openDrawer;
|
|
1768
|
+
openDrawer(taskId);
|
|
1769
|
+
}));
|
|
1770
|
+
bindCopyTaskNameButtons(document);
|
|
1771
|
+
bindRepairPromptButtons(document);
|
|
1772
|
+
bindLessonSedimentationButtons(document);
|
|
1773
|
+
document.querySelectorAll("[data-open-lesson-drawer]").forEach((el) => el.addEventListener("click", (e) => {
|
|
1774
|
+
e.preventDefault();
|
|
1775
|
+
const lessonId = el.dataset.openLessonDrawer;
|
|
1776
|
+
openLessonDrawer(lessonId);
|
|
1777
|
+
}));
|
|
1778
|
+
document.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
1779
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1780
|
+
if (overlay) overlay.addEventListener("click", closeDrawer);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function loadRuntime() {
|
|
1784
|
+
if (state.runtimeLoaded || window.__HARNESS_WORKBENCH__ !== true || !/^https?:$/.test(window.location.protocol)) return;
|
|
1785
|
+
state.runtimeLoaded = true;
|
|
1786
|
+
try {
|
|
1787
|
+
const response = await fetch("/api/runtime", { cache: "no-store" });
|
|
1788
|
+
if (!response.ok) return;
|
|
1789
|
+
state.runtime = await response.json();
|
|
1790
|
+
startRuntimePolling();
|
|
1791
|
+
app();
|
|
1792
|
+
} catch {
|
|
1793
|
+
state.runtime = { mode: "static", csrfToken: "", writableActions: [] };
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function startRuntimePolling() {
|
|
1798
|
+
if (!state.runtime?.autoRefresh || state.runtimePoller) return;
|
|
1799
|
+
state.runtimePoller = setInterval(async () => {
|
|
1800
|
+
try {
|
|
1801
|
+
const response = await fetch("/api/runtime", { cache: "no-store" });
|
|
1802
|
+
if (!response.ok) return;
|
|
1803
|
+
const nextRuntime = await response.json();
|
|
1804
|
+
if (state.runtime?.snapshotVersion && nextRuntime.snapshotVersion !== state.runtime.snapshotVersion) {
|
|
1805
|
+
window.location.reload();
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
state.runtime = nextRuntime;
|
|
1809
|
+
} catch {
|
|
1810
|
+
clearInterval(state.runtimePoller);
|
|
1811
|
+
state.runtimePoller = null;
|
|
1812
|
+
}
|
|
1813
|
+
}, 1500);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
async function completeReviewFromDashboard(taskId) {
|
|
1817
|
+
const result = document.querySelector(`[data-review-result="${CSS.escape(taskId)}"]`);
|
|
1818
|
+
const checkbox = document.querySelector(`[data-review-confirm-check="${CSS.escape(taskId)}"]`);
|
|
1819
|
+
const confirmInput = document.querySelector(`[data-review-confirm-text="${CSS.escape(taskId)}"]`);
|
|
1820
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
1821
|
+
if (!checkbox?.checked) {
|
|
1822
|
+
if (result) result.textContent = t("reviewChecklistRequired");
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
if (!confirmInput?.value || ![task?.shortId, task?.id].includes(confirmInput.value.trim())) {
|
|
1826
|
+
if (result) result.textContent = t("reviewConfirmTextMismatch");
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (result) result.textContent = t("reviewSubmitting");
|
|
1830
|
+
try {
|
|
1831
|
+
const response = await fetch("/api/tasks/review-complete", {
|
|
1832
|
+
method: "POST",
|
|
1833
|
+
headers: {
|
|
1834
|
+
"content-type": "application/json",
|
|
1835
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
1836
|
+
},
|
|
1837
|
+
body: JSON.stringify({
|
|
1838
|
+
taskId,
|
|
1839
|
+
confirmText: confirmInput.value.trim(),
|
|
1840
|
+
reviewer: "Human Reviewer",
|
|
1841
|
+
message: "confirmed from dashboard workbench",
|
|
1842
|
+
}),
|
|
1843
|
+
});
|
|
1844
|
+
const payload = await response.json();
|
|
1845
|
+
if (!response.ok) throw new Error(payload.error || t("reviewCompleteFailed"));
|
|
1846
|
+
if (result) result.textContent = t("reviewCompleteSuccess");
|
|
1847
|
+
setTimeout(() => window.location.reload(), 500);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
if (result) result.textContent = `${t("reviewCompleteFailed")}: ${error.message}`;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function renderDrawerContent(taskId) {
|
|
1854
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
1855
|
+
if (!task) return `<div class="empty">${t("taskNotFound")}</div>`;
|
|
1856
|
+
|
|
1857
|
+
const header = `
|
|
1858
|
+
<div class="task-drawer-header">
|
|
1859
|
+
<div>
|
|
1860
|
+
<h2>${escapeHtml(task.title)}</h2>
|
|
1861
|
+
<p style="font-family: var(--font-mono); font-size: 11px; margin: 4px 0 0; color: var(--muted);">${escapeHtml(task.id)}</p>
|
|
1862
|
+
${taskCopyButton(task, "detail-copy")}
|
|
1863
|
+
</div>
|
|
1864
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
1865
|
+
</div>
|
|
1866
|
+
`;
|
|
1867
|
+
|
|
1868
|
+
const timeline = phaseTimeline(task);
|
|
1869
|
+
const documents = taskDocumentLibrary(task, "");
|
|
1870
|
+
const findings = openFindings(task);
|
|
1871
|
+
const evidence = evidenceList(task);
|
|
1872
|
+
|
|
1873
|
+
const body = `
|
|
1874
|
+
<div class="task-drawer-body stack">
|
|
1875
|
+
<div class="drawer-task-summary">
|
|
1876
|
+
<div>
|
|
1877
|
+
<span>${t("statOverall")}</span>
|
|
1878
|
+
<strong>${task.completion}%</strong>
|
|
1879
|
+
</div>
|
|
1880
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}" class="btn-drawer-trigger">${t("fullView")}</a>
|
|
1881
|
+
</div>
|
|
1882
|
+
${taskStateSummary(task)}
|
|
1883
|
+
${reviewActionPanel(task, { mode: "summary" })}
|
|
1884
|
+
${lessonCandidatePanel(task, { context: "drawer" })}
|
|
1885
|
+
${timeline}
|
|
1886
|
+
${documents}
|
|
1887
|
+
${findings}
|
|
1888
|
+
${evidence}
|
|
1889
|
+
</div>
|
|
1890
|
+
`;
|
|
1891
|
+
|
|
1892
|
+
return header + body;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function openDrawer(taskId) {
|
|
1896
|
+
const drawer = document.getElementById("task-drawer");
|
|
1897
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1898
|
+
if (!drawer || !overlay) return;
|
|
1899
|
+
drawer.innerHTML = renderDrawerContent(taskId);
|
|
1900
|
+
drawer.classList.add("active");
|
|
1901
|
+
overlay.classList.add("active");
|
|
1902
|
+
|
|
1903
|
+
drawer.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
|
|
1904
|
+
drawer.querySelectorAll("[data-render-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1905
|
+
state.renderMode = state.renderMode === "rendered" ? "source" : "rendered";
|
|
1906
|
+
openDrawer(taskId);
|
|
1907
|
+
}));
|
|
1908
|
+
bindCopyTaskNameButtons(drawer);
|
|
1909
|
+
bindRepairPromptButtons(drawer);
|
|
1910
|
+
bindLessonSedimentationButtons(drawer);
|
|
1911
|
+
drawer.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function bindCopyTaskNameButtons(root) {
|
|
1915
|
+
root.querySelectorAll("[data-copy-task-name]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
1916
|
+
event.preventDefault();
|
|
1917
|
+
event.stopPropagation();
|
|
1918
|
+
const taskName = button.dataset.copyTaskName || "";
|
|
1919
|
+
const defaultText = t("copyTaskNameShort");
|
|
1920
|
+
try {
|
|
1921
|
+
await copyText(taskName);
|
|
1922
|
+
button.textContent = t("copyTaskNameSuccess");
|
|
1923
|
+
} catch {
|
|
1924
|
+
button.textContent = t("copyTaskNameFailed");
|
|
1925
|
+
}
|
|
1926
|
+
window.setTimeout(() => {
|
|
1927
|
+
button.textContent = defaultText;
|
|
1928
|
+
}, 1400);
|
|
1929
|
+
}));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function bindRepairPromptButtons(root) {
|
|
1933
|
+
root.querySelectorAll("[data-copy-repair-prompt]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
1934
|
+
event.preventDefault();
|
|
1935
|
+
event.stopPropagation();
|
|
1936
|
+
const prompt = button.dataset.repairPrompt || "";
|
|
1937
|
+
const defaultText = t("copyRepairPrompt");
|
|
1938
|
+
try {
|
|
1939
|
+
await copyText(prompt);
|
|
1940
|
+
button.textContent = t("copyRepairPromptSuccess");
|
|
1941
|
+
} catch {
|
|
1942
|
+
button.textContent = t("copyTaskNameFailed");
|
|
1943
|
+
}
|
|
1944
|
+
window.setTimeout(() => {
|
|
1945
|
+
button.textContent = defaultText;
|
|
1946
|
+
}, 1400);
|
|
1947
|
+
}));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function bindLessonSedimentationButtons(root) {
|
|
1951
|
+
root.querySelectorAll("[data-copy-lesson-prompt]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
1952
|
+
event.preventDefault();
|
|
1953
|
+
event.stopPropagation();
|
|
1954
|
+
const prompt = button.dataset.lessonPrompt || "";
|
|
1955
|
+
const defaultText = t("copyLessonPrompt");
|
|
1956
|
+
try {
|
|
1957
|
+
await copyText(prompt);
|
|
1958
|
+
button.textContent = t("copyRepairPromptSuccess");
|
|
1959
|
+
} catch {
|
|
1960
|
+
button.textContent = t("copyTaskNameFailed");
|
|
1961
|
+
}
|
|
1962
|
+
window.setTimeout(() => {
|
|
1963
|
+
button.textContent = defaultText;
|
|
1964
|
+
}, 1400);
|
|
1965
|
+
}));
|
|
1966
|
+
root.querySelectorAll("[data-create-lesson-sedimentation]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
1967
|
+
event.preventDefault();
|
|
1968
|
+
event.stopPropagation();
|
|
1969
|
+
await createLessonSedimentationFromDashboard(button);
|
|
1970
|
+
}));
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
async function createLessonSedimentationFromDashboard(button) {
|
|
1974
|
+
const taskId = button.dataset.createLessonSedimentation || "";
|
|
1975
|
+
const candidateId = button.dataset.candidateId || "";
|
|
1976
|
+
const result = document.querySelector(`[data-lesson-result="${CSS.escape(`${taskId}:${candidateId}`)}"]`);
|
|
1977
|
+
if (result) result.textContent = t("lessonTaskCreating");
|
|
1978
|
+
button.disabled = true;
|
|
1979
|
+
try {
|
|
1980
|
+
const response = await fetch("/api/tasks/lesson-sedimentation", {
|
|
1981
|
+
method: "POST",
|
|
1982
|
+
headers: {
|
|
1983
|
+
"content-type": "application/json",
|
|
1984
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
1985
|
+
},
|
|
1986
|
+
body: JSON.stringify({ taskId, candidateId }),
|
|
1987
|
+
});
|
|
1988
|
+
const payload = await response.json();
|
|
1989
|
+
if (!response.ok) throw payload;
|
|
1990
|
+
if (result) {
|
|
1991
|
+
result.innerHTML = lessonSedimentationSuccess(payload);
|
|
1992
|
+
bindLessonSedimentationButtons(result);
|
|
1993
|
+
result.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1994
|
+
}
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
button.disabled = false;
|
|
1997
|
+
if (result) result.innerHTML = lessonSedimentationFailure(error);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function lessonSedimentationSuccess(payload) {
|
|
2002
|
+
const followUp = payload?.followUpTask || {};
|
|
2003
|
+
const prompt = payload?.prompt || "";
|
|
2004
|
+
const taskId = followUp.id || "";
|
|
2005
|
+
const openHref = taskId ? `#/tasks/${encodeURIComponent(taskId)}` : "#/review";
|
|
2006
|
+
return `<div class="workbench-action-result success">
|
|
2007
|
+
<strong>${escapeHtml(t("lessonTaskCreated"))}</strong>
|
|
2008
|
+
${taskId ? `<a href="${openHref}">${escapeHtml(t("openFollowUpTask"))}</a>` : ""}
|
|
2009
|
+
${prompt ? `<button data-copy-lesson-prompt="${escapeAttr(taskId || "follow-up")}" data-lesson-prompt="${escapeAttr(prompt)}">${escapeHtml(t("copyLessonPrompt"))}</button>` : ""}
|
|
2010
|
+
</div>`;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function lessonSedimentationFailure(error) {
|
|
2014
|
+
const message = error?.error || error?.message || t("lessonTaskCreateFailed");
|
|
2015
|
+
const recovery = Array.isArray(error?.recovery) ? error.recovery : [];
|
|
2016
|
+
const details = error?.details || {};
|
|
2017
|
+
const existingTask = details.followUpTask || details.existingTask || "";
|
|
2018
|
+
return `<div class="workbench-action-result failed">
|
|
2019
|
+
<strong>${escapeHtml(t("lessonTaskCreateFailed"))}</strong>
|
|
2020
|
+
<span>${escapeHtml(message)}</span>
|
|
2021
|
+
${existingTask ? `<a href="#/tasks/${encodeURIComponent(existingTask)}">${escapeHtml(t("openFollowUpTask"))}</a>` : ""}
|
|
2022
|
+
${recovery.length ? `<ul>${recovery.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : ""}
|
|
2023
|
+
</div>`;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
async function copyText(text) {
|
|
2027
|
+
if (navigator.clipboard?.writeText) {
|
|
2028
|
+
await navigator.clipboard.writeText(text);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
const textarea = document.createElement("textarea");
|
|
2032
|
+
textarea.value = text;
|
|
2033
|
+
textarea.setAttribute("readonly", "");
|
|
2034
|
+
textarea.style.position = "fixed";
|
|
2035
|
+
textarea.style.left = "-9999px";
|
|
2036
|
+
document.body.appendChild(textarea);
|
|
2037
|
+
textarea.select();
|
|
2038
|
+
const copied = document.execCommand("copy");
|
|
2039
|
+
textarea.remove();
|
|
2040
|
+
if (!copied) throw new Error("copy failed");
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function renderLessonDrawerContent(lessonId) {
|
|
2044
|
+
const lesson = lessonDocuments().find((item) => item.id === lessonId);
|
|
2045
|
+
|
|
2046
|
+
if (!lesson) {
|
|
2047
|
+
return `<div class="task-drawer-header">
|
|
2048
|
+
<h2>${escapeHtml(lessonId)}</h2>
|
|
2049
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
2050
|
+
</div>
|
|
2051
|
+
<div class="task-drawer-body">
|
|
2052
|
+
<div class="empty">${t("lessonNotFound")}</div>
|
|
2053
|
+
</div>`;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const doc = lesson.doc || findDocument(lesson.path);
|
|
2057
|
+
|
|
2058
|
+
const header = `
|
|
2059
|
+
<div class="task-drawer-header">
|
|
2060
|
+
<div>
|
|
2061
|
+
<h2>${escapeHtml(lessonId)}</h2>
|
|
2062
|
+
<p style="font-size: 12px; margin: 4px 0 0; color: var(--muted); font-weight: 600;">${escapeHtml(lesson.title || lesson.path)}</p>
|
|
2063
|
+
</div>
|
|
2064
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
2065
|
+
</div>
|
|
2066
|
+
`;
|
|
2067
|
+
|
|
2068
|
+
let markdownBody = "";
|
|
2069
|
+
if (doc && doc.content) {
|
|
2070
|
+
markdownBody = `<div class="markdown">${window.HarnessMarkdown.render(doc.content, "rendered")}</div>`;
|
|
2071
|
+
} else {
|
|
2072
|
+
markdownBody = `
|
|
2073
|
+
<div style="margin-bottom: 20px; background: var(--paper-2); padding: 16px; border-radius: 8px; border: 1px dashed var(--line);">
|
|
2074
|
+
<p style="margin: 0; font-size: 13px; color: var(--muted);">${t("lessonDocMissing")}</p>
|
|
2075
|
+
</div>
|
|
2076
|
+
`;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
const body = `
|
|
2080
|
+
<div class="task-drawer-body stack">
|
|
2081
|
+
${markdownBody}
|
|
2082
|
+
</div>
|
|
2083
|
+
`;
|
|
2084
|
+
|
|
2085
|
+
return header + body;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function openLessonDrawer(lessonId) {
|
|
2089
|
+
const drawer = document.getElementById("task-drawer");
|
|
2090
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
2091
|
+
if (!drawer || !overlay) return;
|
|
2092
|
+
drawer.innerHTML = renderLessonDrawerContent(lessonId);
|
|
2093
|
+
drawer.classList.add("active");
|
|
2094
|
+
overlay.classList.add("active");
|
|
2095
|
+
|
|
2096
|
+
drawer.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function closeDrawer() {
|
|
2100
|
+
const drawer = document.getElementById("task-drawer");
|
|
2101
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
2102
|
+
if (drawer) drawer.classList.remove("active");
|
|
2103
|
+
if (overlay) overlay.classList.remove("active");
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function ledgerPanel() {
|
|
2107
|
+
const ledgerTable = (bundle.tables?.tables || []).find((table) => table.kind === "harness-ledger");
|
|
2108
|
+
const rows = ledgerTable?.rows || [];
|
|
2109
|
+
|
|
2110
|
+
let closedCount = 0;
|
|
2111
|
+
let openCount = 0;
|
|
2112
|
+
let blockedCount = 0;
|
|
2113
|
+
|
|
2114
|
+
let lessonsReviewed = 0;
|
|
2115
|
+
let lessonsTotal = 0;
|
|
2116
|
+
|
|
2117
|
+
let evidenceAudited = 0;
|
|
2118
|
+
let evidenceTotal = 0;
|
|
2119
|
+
|
|
2120
|
+
for (const row of rows) {
|
|
2121
|
+
const cells = row.cells || {};
|
|
2122
|
+
const status = String(cells.Status || cells["\u72b6\u6001"] || "").toLowerCase();
|
|
2123
|
+
if (status.includes("close") || status.includes("done") || status.includes("\u7ed3") || status.includes("\u5b8c")) {
|
|
2124
|
+
closedCount++;
|
|
2125
|
+
} else if (status.includes("block") || status.includes("\u963b")) {
|
|
2126
|
+
blockedCount++;
|
|
2127
|
+
} else {
|
|
2128
|
+
openCount++;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
const lesson = String(cells.Lessons || cells["\u7ecf\u9a8c"] || cells["\u7ecf\u9a8c\u5ba1\u67e5"] || cells["Lesson"] || "");
|
|
2132
|
+
if (lesson) {
|
|
2133
|
+
lessonsTotal++;
|
|
2134
|
+
if (lesson.toLowerCase().includes("pass") || lesson.includes("\u901a\u8fc7") || lesson.includes("\u5c31\u7eea") || lesson.toLowerCase().includes("checked") || lesson.toLowerCase().includes("done")) {
|
|
2135
|
+
lessonsReviewed++;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
const evidence = String(cells.Evidence || cells["\u8bc1\u636e"] || cells["\u9a8c\u8bc1\u8bc1\u636e"] || cells["Evidence Checked"] || "");
|
|
2140
|
+
if (evidence) {
|
|
2141
|
+
evidenceTotal++;
|
|
2142
|
+
if (evidence.toLowerCase().includes("pass") || evidence.includes("\u901a\u8fc7") || evidence.toLowerCase().includes("present") || evidence.toLowerCase().includes("verified") || evidence.toLowerCase().includes("done")) {
|
|
2143
|
+
evidenceAudited++;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const total = closedCount + openCount + blockedCount || 1;
|
|
2149
|
+
const closedPct = Math.round((closedCount / total) * 100);
|
|
2150
|
+
const openPct = Math.round((openCount / total) * 100);
|
|
2151
|
+
const blockedPct = total - closedPct - openPct;
|
|
2152
|
+
|
|
2153
|
+
const lessonsPct = lessonsTotal ? Math.round((lessonsReviewed / lessonsTotal) * 100) : 0;
|
|
2154
|
+
const evidencePct = evidenceTotal ? Math.round((evidenceAudited / evidenceTotal) * 100) : 0;
|
|
2155
|
+
|
|
2156
|
+
if (rows.length === 0) return "";
|
|
2157
|
+
|
|
2158
|
+
return `<section class="ledger-panel">
|
|
2159
|
+
<h2>${t("ssotLedger")}</h2>
|
|
2160
|
+
<div class="ledger-split-bar" title="${t("tagClosed")}: ${closedCount}, ${t("tagOpen")}: ${openCount}, ${t("tagBlocked")}: ${blockedCount}">
|
|
2161
|
+
<div class="ledger-split-segment closed" style="width: ${closedPct}%"></div>
|
|
2162
|
+
<div class="ledger-split-segment open" style="width: ${openPct}%"></div>
|
|
2163
|
+
<div class="ledger-split-segment blocked" style="width: ${Math.max(0, blockedPct)}%"></div>
|
|
2164
|
+
</div>
|
|
2165
|
+
<div class="ledger-split-legend">
|
|
2166
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot closed"></i>${t("tagClosed")} (${closedCount})</span>
|
|
2167
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot open"></i>${t("tagOpen")} (${openCount})</span>
|
|
2168
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot blocked"></i>${t("tagBlocked")} (${blockedCount})</span>
|
|
2169
|
+
</div>
|
|
2170
|
+
<div class="ledger-gauge-row">
|
|
2171
|
+
<div class="ledger-gauge-card">
|
|
2172
|
+
<span>${t("lessonsCheckRate")}</span>
|
|
2173
|
+
<strong>${lessonsPct}%</strong>
|
|
2174
|
+
</div>
|
|
2175
|
+
<div class="ledger-gauge-card">
|
|
2176
|
+
<span>${t("evidenceAuditRate")}</span>
|
|
2177
|
+
<strong>${evidencePct}%</strong>
|
|
2178
|
+
</div>
|
|
2179
|
+
</div>
|
|
2180
|
+
</section>`;
|
|
421
2181
|
}
|
|
422
2182
|
|
|
423
2183
|
function escapeHtml(value) {
|
|
@@ -432,4 +2192,6 @@ function escapeAttr(value) {
|
|
|
432
2192
|
return escapeHtml(value).replaceAll("'", "'");
|
|
433
2193
|
}
|
|
434
2194
|
|
|
2195
|
+
window.addEventListener("hashchange", app);
|
|
435
2196
|
app();
|
|
2197
|
+
loadRuntime();
|