coding-agent-harness 1.0.1 → 1.0.2
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 +19 -0
- package/README.en-US.md +14 -0
- package/README.md +111 -86
- package/README.zh-CN.md +270 -0
- package/SKILL.md +116 -189
- package/docs-release/README.md +72 -5
- package/docs-release/architecture/overview.md +286 -28
- package/docs-release/architecture/overview.zh-CN.md +288 -0
- package/docs-release/assets/dashboard-overview-en.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 +214 -0
- package/docs-release/guides/agent-installation.md +123 -26
- package/docs-release/guides/document-audience-and-surfaces.en-US.md +112 -0
- package/docs-release/guides/document-audience-and-surfaces.md +112 -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 +384 -0
- package/docs-release/guides/legacy-migration-agent-prompt.zh-CN.md +361 -0
- package/docs-release/guides/migration-playbook.en-US.md +325 -0
- package/docs-release/guides/migration-playbook.md +329 -0
- package/docs-release/guides/parent-control-repository-pattern.en-US.md +252 -0
- package/docs-release/guides/parent-control-repository-pattern.md +252 -0
- package/docs-release/guides/repository-operating-models.en-US.md +196 -0
- package/docs-release/guides/repository-operating-models.md +196 -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/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 +3 -1
- package/references/agents-md-pattern.md +3 -3
- package/references/docs-directory-standard.md +47 -3
- package/references/external-source-intake-standard.md +75 -0
- package/references/harness-ledger.md +5 -3
- package/references/legacy-12-phase-bootstrap.md +41 -0
- package/references/lessons-governance.md +23 -6
- package/references/planning-loop.md +41 -3
- package/references/project-onboarding-audit.md +10 -0
- package/references/repo-governance-standard.md +2 -0
- package/references/testing-standard.md +50 -0
- package/references/walkthrough-closeout.md +6 -5
- package/scripts/check-harness.mjs +76 -35
- package/scripts/harness.mjs +303 -12
- package/scripts/lib/capability-registry.mjs +533 -0
- package/scripts/lib/check-profiles.mjs +510 -0
- package/scripts/lib/core-shared.mjs +186 -0
- package/scripts/lib/dashboard-data.mjs +389 -0
- package/scripts/lib/dashboard-workbench.mjs +217 -0
- package/scripts/lib/dashboard-writer.mjs +93 -2
- package/scripts/lib/harness-core.mjs +10 -1318
- package/scripts/lib/lesson-maintenance.mjs +145 -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/task-lifecycle.mjs +755 -0
- package/scripts/lib/task-scanner.mjs +682 -0
- package/scripts/smoke-dashboard.mjs +22 -0
- package/scripts/test-harness.mjs +926 -14
- package/templates/AGENTS.md.template +41 -30
- 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 +41 -0
- package/templates/dashboard/assets/app-src/10-router.js +76 -0
- package/templates/dashboard/assets/app-src/20-overview.js +235 -0
- package/templates/dashboard/assets/app-src/30-tasks.js +563 -0
- package/templates/dashboard/assets/app-src/40-modules.js +58 -0
- package/templates/dashboard/assets/app-src/45-review.js +128 -0
- package/templates/dashboard/assets/app-src/50-migration.js +169 -0
- package/templates/dashboard/assets/app-src/60-shared.js +61 -0
- package/templates/dashboard/assets/app-src/90-bindings.js +382 -0
- package/templates/dashboard/assets/app.css +2575 -310
- package/templates/dashboard/assets/app.js +1498 -307
- package/templates/dashboard/assets/app.manifest.json +11 -0
- package/templates/dashboard/assets/i18n.js +429 -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/planning/brief.md +32 -0
- package/templates/planning/lesson_candidates.md +58 -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/task_plan.md +7 -5
- package/templates/planning/{visual_roadmap.md → visual_map.md} +24 -2
- package/templates/reference/docs-library-standard.md +31 -0
- package/templates/reference/execution-workflow-standard.md +4 -2
- package/templates/reference/external-source-intake-standard.md +82 -0
- package/templates/reference/harness-ledger-standard.md +1 -0
- package/templates/reference/repo-governance-standard.md +6 -4
- package/templates/reference/walkthrough-standard.md +2 -1
- package/templates/walkthrough/walkthrough-template.md +2 -2
- package/templates-zh-CN/AGENTS.md.template +69 -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/planning/brief.md +32 -0
- package/templates-zh-CN/planning/lesson_candidates.md +58 -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/task_plan.md +10 -4
- package/templates-zh-CN/planning/{visual_roadmap.md → visual_map.md} +21 -2
- package/templates-zh-CN/reference/docs-library-standard.md +35 -0
- package/templates-zh-CN/reference/execution-workflow-standard.md +9 -2
- package/templates-zh-CN/reference/external-source-intake-standard.md +82 -0
- package/templates-zh-CN/reference/harness-ledger-standard.md +5 -2
- package/templates-zh-CN/reference/repo-governance-standard.md +2 -0
- package/templates-zh-CN/reference/walkthrough-standard.md +4 -4
- package/templates-zh-CN/walkthrough/Closeout-SSoT.md +2 -2
- package/templates-zh-CN/walkthrough/walkthrough-template.md +2 -2
- 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
|
@@ -1,383 +1,1232 @@
|
|
|
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
|
+
runtime: { mode: "static", csrfToken: "", writableActions: [] },
|
|
19
|
+
runtimeLoaded: false,
|
|
20
|
+
runtimePoller: null,
|
|
10
21
|
};
|
|
11
22
|
|
|
12
|
-
const
|
|
23
|
+
const taskPageSize = 25;
|
|
24
|
+
const taskGroupsPerPage = 8;
|
|
25
|
+
const warningPageSize = 18;
|
|
26
|
+
|
|
13
27
|
const taskDocTabs = [
|
|
14
|
-
["
|
|
28
|
+
["brief", "brief.md"],
|
|
29
|
+
["taskPlan", "task_plan.md"],
|
|
15
30
|
["strategy", "execution_strategy.md"],
|
|
16
|
-
["
|
|
31
|
+
["visualMap", "visual_map.md"],
|
|
32
|
+
["legacyRoadmap", "visual_roadmap.md"],
|
|
17
33
|
["progress", "progress.md"],
|
|
18
34
|
["review", "review.md"],
|
|
19
35
|
["findings", "findings.md"],
|
|
36
|
+
["walkthrough", "__walkthrough__"],
|
|
20
37
|
["references", "references/INDEX.md"],
|
|
21
38
|
["artifacts", "artifacts/INDEX.md"],
|
|
22
39
|
];
|
|
23
40
|
|
|
24
41
|
function t(key) {
|
|
25
|
-
return
|
|
42
|
+
return labels[key] || key;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setLocale(nextLocale) {
|
|
46
|
+
locale = window.HarnessI18n?.[nextLocale] ? nextLocale : "en";
|
|
47
|
+
labels = window.HarnessI18n?.[locale] || {};
|
|
48
|
+
localStorage.setItem("harness.locale", locale);
|
|
26
49
|
}
|
|
27
50
|
|
|
28
51
|
function app() {
|
|
29
52
|
const systemTheme = window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
30
53
|
document.documentElement.dataset.theme = state.theme === "system" ? systemTheme : state.theme;
|
|
31
|
-
document.documentElement.
|
|
32
|
-
document.documentElement.lang = "en";
|
|
54
|
+
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
|
33
55
|
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>`;
|
|
56
|
+
root.innerHTML = shell();
|
|
48
57
|
bind();
|
|
49
58
|
}
|
|
50
59
|
|
|
51
|
-
function
|
|
52
|
-
return `<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<div>
|
|
58
|
-
<p class="eyebrow">Coding Agent Harness Dashboard</p>
|
|
59
|
-
<h1>${escapeHtml(pageTitle())}</h1>
|
|
60
|
-
</div>
|
|
61
|
-
<div class="controls">
|
|
62
|
-
<div class="control">
|
|
63
|
-
<button data-theme="light" class="${state.theme === "light" ? "active" : ""}">${t("light")}</button>
|
|
64
|
-
<button data-theme="dark" class="${state.theme === "dark" ? "active" : ""}">${t("dark")}</button>
|
|
65
|
-
<button data-theme="system" class="${state.theme === "system" ? "active" : ""}">${t("system")}</button>
|
|
60
|
+
function shell() {
|
|
61
|
+
return `<div class="visibility-shell">
|
|
62
|
+
<header class="hero">
|
|
63
|
+
<div class="hero-copy">
|
|
64
|
+
<p class="eyebrow">${t("eyebrow")}</p>
|
|
65
|
+
<h1>${escapeHtml(projectName())} ${t("projectCockpit")}</h1>
|
|
66
66
|
</div>
|
|
67
|
-
<div class="
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
<div class="hero-actions">
|
|
68
|
+
${routeLink("#/", t("overview"), "overview")}
|
|
69
|
+
${routeLink("#/tasks", t("taskIndex"), "tasks")}
|
|
70
|
+
${routeLink("#/review", t("reviewQueue"), "review")}
|
|
71
|
+
${routeLink("#/modules", t("moduleView"), "modules")}
|
|
72
|
+
<button data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
|
|
73
|
+
<button data-theme-toggle>${themeLabel()}</button>
|
|
70
74
|
</div>
|
|
71
|
-
</
|
|
75
|
+
</header>
|
|
76
|
+
${renderRoute()}
|
|
77
|
+
<div id="drawer-overlay" class="drawer-overlay"></div>
|
|
78
|
+
<div id="task-drawer" class="task-drawer"></div>
|
|
72
79
|
</div>`;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
function
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
82
|
+
function renderRoute() {
|
|
83
|
+
const route = currentRoute();
|
|
84
|
+
if (route.name === "task") return taskDetail(route);
|
|
85
|
+
if (route.name === "review") return reviewQueue();
|
|
86
|
+
if (route.name === "modules") return modulesView(route.id);
|
|
87
|
+
if (route.name === "tasks") return taskIndex();
|
|
88
|
+
return overview();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function currentRoute() {
|
|
92
|
+
const hash = window.location.hash || "#/";
|
|
93
|
+
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(decodeURIComponent);
|
|
94
|
+
if (parts[0] === "tasks" && parts[1]) return { name: "task", id: parts[1], doc: parts[2] === "docs" ? parts[3] || "" : "" };
|
|
95
|
+
if (parts[0] === "review") return { name: "review" };
|
|
96
|
+
if (parts[0] === "modules") return { name: "modules", id: parts[1] || "" };
|
|
97
|
+
if (parts[0] === "tasks") return { name: "tasks" };
|
|
98
|
+
return { name: "overview" };
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (state.page === "tasks") return withDrawer(taskTable());
|
|
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();
|
|
101
|
+
function routeLink(hash, text, routeName) {
|
|
102
|
+
const active = currentRoute().name === routeName;
|
|
103
|
+
return `<a class="${active ? "active" : ""}" href="${hash}">${escapeHtml(text)}</a>`;
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
function overview() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
return `<div class="dashboard-grid">
|
|
108
|
+
<main class="dashboard-main stack">
|
|
109
|
+
${flowPanel()}
|
|
110
|
+
${activeTaskBriefs()}
|
|
111
|
+
${migrationSummaryPanel()}
|
|
112
|
+
</main>
|
|
113
|
+
<aside class="dashboard-sidebar stack">
|
|
114
|
+
${statusStrip()}
|
|
115
|
+
${ledgerPanel()}
|
|
116
|
+
${healthPanel()}
|
|
117
|
+
${lessonPanel()}
|
|
118
|
+
</aside>
|
|
119
|
+
</div>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function statusStrip() {
|
|
123
|
+
const status = bundle.status?.checkState?.status || "unknown";
|
|
124
|
+
const failures = bundle.status?.checkState?.failures || 0;
|
|
125
|
+
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
126
|
+
const tasks = bundle.status?.tasks || [];
|
|
127
|
+
const summary = bundle.status?.summary || {};
|
|
128
|
+
const visual = summary.visualMapCoverage || {};
|
|
129
|
+
const withBrief = tasks.filter((task) => task.briefSource === "standalone").length;
|
|
130
|
+
return `<section class="status-card-group">
|
|
131
|
+
<div class="status-primary ${status}">
|
|
132
|
+
<span>${t("readiness")}</span>
|
|
133
|
+
<strong>${label(status)}</strong>
|
|
134
|
+
<p>${nextActionText()}</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="metrics-grid">
|
|
137
|
+
${metric(t("tasks"), tasks.length)}
|
|
138
|
+
${metric(t("briefCoverage"), `${withBrief}/${tasks.length}`)}
|
|
139
|
+
${metric(t("visualMapCoverage"), `${visual.canonical || 0}/${summary.visualMapRequiredCount || tasks.length}`)}
|
|
140
|
+
${metric(t("fullCutover"), summary.fullCutoverEligible ? t("ready") : t("notReady"))}
|
|
141
|
+
${metric(t("legacyVisualOnly"), summary.legacyVisualOnlyCount || 0)}
|
|
142
|
+
${metric(t("weakBrief"), summary.weakBriefCount || 0)}
|
|
143
|
+
${metric(t("blockers"), failures)}
|
|
144
|
+
${metric(t("advice"), warnings)}
|
|
112
145
|
</div>
|
|
113
|
-
${drawer()}
|
|
114
146
|
</section>`;
|
|
115
147
|
}
|
|
116
148
|
|
|
117
|
-
function metric(
|
|
118
|
-
return `<div class="metric"><span>${
|
|
149
|
+
function metric(labelText, value) {
|
|
150
|
+
return `<div class="metric"><span>${escapeHtml(labelText)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
119
151
|
}
|
|
120
152
|
|
|
121
153
|
function nextActionText() {
|
|
122
154
|
const failures = bundle.status?.checkState?.failures || 0;
|
|
123
|
-
if (failures > 0) return "
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
155
|
+
if (failures > 0) return t("resolveBlockers");
|
|
156
|
+
const missingBriefs = (bundle.status?.tasks || []).filter((task) => task.briefSource !== "standalone").length;
|
|
157
|
+
if (missingBriefs > 0) return `${missingBriefs} ${t("missingBriefs")}`;
|
|
158
|
+
const warnings = bundle.status?.checkState?.warnings || 0;
|
|
159
|
+
if (warnings > 0) return t("reviewAdvice");
|
|
160
|
+
return t("noBlockers");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function flowPanel() {
|
|
164
|
+
const tasks = bundle.status?.tasks || [];
|
|
165
|
+
const total = tasks.length;
|
|
166
|
+
if (total === 0) return "";
|
|
167
|
+
const active = tasks.filter((task) => isActiveTaskState(task.state)).length;
|
|
168
|
+
const done = tasks.filter((task) => !isActiveTaskState(task.state) && (task.state === "done" || task.completion === 100)).length;
|
|
169
|
+
const planned = Math.max(0, total - done - active);
|
|
170
|
+
const pct = (n) => total > 0 ? Math.round((n / total) * 100) : 0;
|
|
171
|
+
return `<section class="flow-panel">
|
|
172
|
+
<div class="section-head">
|
|
173
|
+
<div>
|
|
174
|
+
<p class="eyebrow">${t("firstLook")}</p>
|
|
175
|
+
<h2>${t("projectProgress")}</h2>
|
|
176
|
+
</div>
|
|
177
|
+
<span class="subtle">${done}/${total} ${t("completed")}</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="progress-bar-container">
|
|
180
|
+
<div class="progress-bar">
|
|
181
|
+
${done > 0 ? `<div class="progress-segment done" style="width:${pct(done)}%" title="${t("done")}: ${done}"></div>` : ""}
|
|
182
|
+
${active > 0 ? `<div class="progress-segment active" style="width:${pct(active)}%" title="${t("active")}: ${active}"></div>` : ""}
|
|
183
|
+
${planned > 0 ? `<div class="progress-segment planned" style="width:${pct(planned)}%" title="${t("planned")}: ${planned}"></div>` : ""}
|
|
184
|
+
</div>
|
|
185
|
+
<div class="progress-legend">
|
|
186
|
+
<span class="legend-item"><span class="legend-dot done"></span>${t("done")} ${done}</span>
|
|
187
|
+
<span class="legend-item"><span class="legend-dot active"></span>${t("active")} ${active}</span>
|
|
188
|
+
<span class="legend-item"><span class="legend-dot planned"></span>${t("planned")} ${planned}</span>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
${usesAggregateFlow() ? migrationRunwayBreakdown() : ""}
|
|
147
192
|
</section>`;
|
|
148
193
|
}
|
|
149
194
|
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
function projectMermaid() {
|
|
196
|
+
if (usesAggregateFlow()) return migrationAggregateMermaid();
|
|
197
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
198
|
+
const preferredTypes = graph.nodes?.some((node) => node.type === "module") ? ["module", "step"] : ["task", "phase"];
|
|
199
|
+
const nodes = (graph.nodes || [])
|
|
200
|
+
.filter((node) => preferredTypes.includes(node.type))
|
|
201
|
+
.filter((node) => node.type !== "phase" || ["in_progress", "review", "blocked", "done"].includes(node.state))
|
|
202
|
+
.slice(0, 28);
|
|
203
|
+
if (nodes.length < 2) return mermaidFromBriefs();
|
|
204
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
205
|
+
const lines = ["flowchart LR"];
|
|
206
|
+
let edgeCount = 0;
|
|
207
|
+
for (const edge of graph.edges || []) {
|
|
208
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) continue;
|
|
209
|
+
lines.push(` ${mermaidId(edge.from)}["${mermaidLabel(edge.from)}"] --> ${mermaidId(edge.to)}["${mermaidLabel(edge.to)}"]`);
|
|
210
|
+
edgeCount += 1;
|
|
211
|
+
if (edgeCount >= 34) break;
|
|
212
|
+
}
|
|
213
|
+
if (edgeCount === 0) {
|
|
214
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
215
|
+
lines.push(` ${mermaidId(nodes[index - 1].id)}["${mermaidLabel(nodes[index - 1].id)}"] --> ${mermaidId(nodes[index].id)}["${mermaidLabel(nodes[index].id)}"]`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function usesAggregateFlow() {
|
|
222
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
223
|
+
const taskCount = (bundle.status?.tasks || []).length;
|
|
224
|
+
const taskNodes = (graph.nodes || []).filter((node) => node.type === "task").length;
|
|
225
|
+
const usefulEdges = (graph.edges || []).filter((edge) => ["depends_on", "current_step"].includes(edge.type)).length;
|
|
226
|
+
return taskCount > 80 || taskNodes > 80 || ((graph.nodes || []).length > 80 && usefulEdges < 6);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function migrationAggregateMermaid() {
|
|
230
|
+
const tasks = bundle.status?.tasks || [];
|
|
231
|
+
const warnings = warningQueue();
|
|
232
|
+
const activeContracts = warnings.filter((warning) => warning.phase === "active-task-contracts").length;
|
|
233
|
+
const moduleCount = new Set(tasks.map(taskModuleKey)).size;
|
|
234
|
+
const reviewWarnings = warnings.filter((warning) => ["review-evidence", "strict-cutover"].includes(warning.phase)).length;
|
|
235
|
+
const lines = [
|
|
236
|
+
"flowchart LR",
|
|
237
|
+
` baseline["${t("runwayBaseline")}\\n${tasks.length} ${t("tasks")}"] --> triage["${t("runwayTriage")}\\n${warnings.length} ${t("warnings")}"]`,
|
|
238
|
+
` triage --> contracts["${t("runwayContracts")}\\n${activeContracts} ${t("items")}"]`,
|
|
239
|
+
` contracts --> modules["${t("runwayModules")}\\n${moduleCount} ${t("groups")}"]`,
|
|
240
|
+
` modules --> cutover["${t("runwayCutover")}\\n${reviewWarnings} ${t("items")}"]`,
|
|
241
|
+
];
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function migrationRunwayBreakdown() {
|
|
246
|
+
const tasks = bundle.status?.tasks || [];
|
|
247
|
+
const warnings = warningQueue();
|
|
248
|
+
const phases = [
|
|
249
|
+
["baseline", t("runwayBaseline"), tasks.length, t("tasks"), "#/tasks"],
|
|
250
|
+
["triage", t("runwayTriage"), warnings.length, t("warnings"), "#/"],
|
|
251
|
+
["active-task-contracts", t("runwayContracts"), warnings.filter((warning) => warning.phase === "active-task-contracts").length, t("items"), "#/"],
|
|
252
|
+
["module-classification", t("runwayModules"), new Set(tasks.map(taskModuleKey)).size, t("groups"), "#/tasks"],
|
|
253
|
+
["strict-cutover", t("runwayCutover"), warnings.filter((warning) => warning.phase === "strict-cutover").length, t("items"), "#/"],
|
|
254
|
+
];
|
|
255
|
+
return `<div class="runway-breakdown">
|
|
256
|
+
${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("")}
|
|
257
|
+
</div>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function mermaidFromBriefs() {
|
|
261
|
+
const brief = activeTasks().map((task) => taskDocument(task, "brief.md")).find((doc) => doc?.content?.includes("```mermaid"));
|
|
262
|
+
const match = brief?.content.match(/```mermaid\s*([\s\S]*?)```/i);
|
|
263
|
+
return match ? match[1].trim() : "";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function graphSummary() {
|
|
267
|
+
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
268
|
+
if (usesAggregateFlow()) return `${t("aggregateMigrationView")} · ${(bundle.status?.tasks || []).length} ${t("tasks")}`;
|
|
269
|
+
return `${graph.nodes?.length || 0} ${t("nodes")} · ${graph.edges?.length || 0} ${t("edges")}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function activeTaskBriefs() {
|
|
273
|
+
const tasks = activeTasks().slice(0, 8);
|
|
274
|
+
return `<section class="task-briefs">
|
|
275
|
+
<div class="section-head">
|
|
276
|
+
<div>
|
|
277
|
+
<p class="eyebrow">${t("currentWork")}</p>
|
|
278
|
+
<h2>${t("activeBriefs")}</h2>
|
|
279
|
+
</div>
|
|
280
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="brief-grid">${tasks.map((task) => taskBriefCard(task, { compact: false })).join("") || emptyState(t("noActiveTasks"))}</div>
|
|
174
283
|
</section>`;
|
|
175
284
|
}
|
|
176
285
|
|
|
177
|
-
function
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
function moduleTable() {
|
|
184
|
-
const tables = (bundle.tables?.tables || []).filter((table) => table.kind === "module-registry");
|
|
185
|
-
const graph = graphPanel();
|
|
186
|
-
if (tables.length === 0) return `${graph}${emptyTable(t("modules"), "No Module Registry found.")}`;
|
|
187
|
-
return `${graph}${genericTables(t("modules"), tables)}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function evidenceTable() {
|
|
191
|
-
const items = evidenceItems();
|
|
192
|
-
return tablePanel(t("evidence"), [t("origin"), t("task"), t("state"), t("title"), t("affected")], items.map((item) => [
|
|
193
|
-
escapeHtml(label(item.source)),
|
|
194
|
-
escapeHtml(item.task || "-"),
|
|
195
|
-
tag(item.state || "present", label(item.state || "present")),
|
|
196
|
-
escapeHtml(item.title),
|
|
197
|
-
escapeHtml(item.affected || ""),
|
|
198
|
-
]));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function evidenceItems() {
|
|
202
|
-
const taskEvidence = (bundle.status?.tasks || []).flatMap((task) => [
|
|
203
|
-
...(task.evidence || []).map((item) => ({
|
|
204
|
-
source: item.type || "task-progress",
|
|
205
|
-
task: task.title,
|
|
206
|
-
state: item.status || "present",
|
|
207
|
-
title: item.summary || item.id,
|
|
208
|
-
affected: item.path || "",
|
|
209
|
-
})),
|
|
210
|
-
...(task.risks || []).map((item) => ({
|
|
211
|
-
source: "task-review",
|
|
212
|
-
task: task.title,
|
|
213
|
-
state: item.open ? "open" : "closed",
|
|
214
|
-
title: item.summary || item.id,
|
|
215
|
-
affected: `${item.severity || ""}${item.blocksRelease ? " · blocks" : ""}`.trim(),
|
|
216
|
-
})),
|
|
217
|
-
]);
|
|
218
|
-
const qaTables = (bundle.tables?.tables || [])
|
|
219
|
-
.filter((table) => ["task-review", "regression-ssot", "cadence-ledger"].includes(table.kind))
|
|
220
|
-
.flatMap((table) => table.rows.slice(0, 12).map((row) => {
|
|
221
|
-
const cells = row.cells || {};
|
|
222
|
-
return {
|
|
223
|
-
source: table.kind,
|
|
224
|
-
task: cells.Task || cells.Module || cells.ID || "-",
|
|
225
|
-
state: cells.Status || cells.State || cells.Open || cells.Verdict || "present",
|
|
226
|
-
title: cells.Finding || cells.Summary || cells.Check || cells.Item || cells.Title || table.source,
|
|
227
|
-
affected: cells["Evidence Checked"] || cells.Path || cells.Owner || cells["Required Action"] || table.source,
|
|
228
|
-
};
|
|
229
|
-
}));
|
|
230
|
-
return [...taskEvidence, ...qaTables];
|
|
286
|
+
function activeTasks() {
|
|
287
|
+
const tasks = bundle.status?.tasks || [];
|
|
288
|
+
const active = tasks.filter((task) => isActiveTaskState(task.state) || ["planned", "not_started"].includes(task.state));
|
|
289
|
+
if (active.length > 0) return active;
|
|
290
|
+
return tasks.filter((task) => task.briefSource === "standalone").slice(0, 6);
|
|
231
291
|
}
|
|
232
292
|
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
if (tables.length === 0) return emptyTable(t("lessons"), "No Lessons SSoT found.");
|
|
236
|
-
return genericTables(t("lessons"), tables);
|
|
293
|
+
function isActiveTaskState(state) {
|
|
294
|
+
return ["active", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(state);
|
|
237
295
|
}
|
|
238
296
|
|
|
239
|
-
function
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
escapeHtml(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
297
|
+
function taskBriefCard(task, { compact = true } = {}) {
|
|
298
|
+
const doc = taskDocument(task, "brief.md");
|
|
299
|
+
const summaryText = doc ? getBriefSummary(doc.content) : t("missingBriefExplain");
|
|
300
|
+
return `<article class="brief-card ${compact ? "compact" : ""}">
|
|
301
|
+
<div class="card-head">
|
|
302
|
+
<div>
|
|
303
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}">${escapeHtml(task.title)}</a>
|
|
304
|
+
<p>${escapeHtml(task.id)}</p>
|
|
305
|
+
</div>
|
|
306
|
+
${tag(task.state)}
|
|
307
|
+
</div>
|
|
308
|
+
${progressBar(task.completion)}
|
|
309
|
+
<div class="brief-content">
|
|
310
|
+
<p class="brief-teaser">${escapeHtml(summaryText)}</p>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="card-actions">
|
|
313
|
+
<button class="btn-drawer-trigger" data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
|
|
314
|
+
</div>
|
|
315
|
+
</article>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getBriefSummary(content) {
|
|
319
|
+
if (!content) return "";
|
|
320
|
+
let text = content
|
|
321
|
+
.replace(/#+\s+/g, "")
|
|
322
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
323
|
+
.replace(/[*_`]/g, "")
|
|
324
|
+
.replace(/-\s+/g, "")
|
|
325
|
+
.replace(/>\s+/g, "")
|
|
326
|
+
.replaceAll("\n", " ")
|
|
327
|
+
.replace(/\s+/g, " ")
|
|
328
|
+
.trim();
|
|
329
|
+
if (text.length > 140) text = text.slice(0, 137) + "...";
|
|
330
|
+
return text;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function generatedBrief(task) {
|
|
334
|
+
const phaseText = (task.phases || []).slice(0, 6).map((phase) => `<li><strong>${escapeHtml(phase.id)}</strong> ${escapeHtml(phase.output || phase.state)} · ${phase.completion}%</li>`).join("");
|
|
335
|
+
return `<div class="missing-brief">
|
|
336
|
+
<strong>${t("visibilityBriefMissing")}</strong>
|
|
337
|
+
<p>${t("missingBriefExplain")}</p>
|
|
338
|
+
<ul>${phaseText || `<li>${t("noPhaseData")}</li>`}</ul>
|
|
339
|
+
</div>`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function clampCompletion(value) {
|
|
343
|
+
const number = Number(value) || 0;
|
|
344
|
+
return Math.max(0, Math.min(100, Math.round(number)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function stateToColorVar(state) {
|
|
348
|
+
const map = { in_progress: "--accent", review: "--accent-2", blocked: "--danger", done: "--ok", planned: "--muted", not_started: "--muted" };
|
|
349
|
+
return map[state] || "--muted";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function taskToolbarCard(filteredCount) {
|
|
353
|
+
return `<section class="sidebar-card">
|
|
354
|
+
<h3>${t("filterTitle")}</h3>
|
|
355
|
+
<div class="input-group">
|
|
356
|
+
<input data-search value="${escapeAttr(state.query)}" placeholder="${t("searchPlaceholder")}" aria-label="${t("searchTasks")}">
|
|
357
|
+
</div>
|
|
358
|
+
<div class="select-group">
|
|
359
|
+
<label>${t("stateFilter")}</label>
|
|
360
|
+
<select data-state-filter aria-label="${t("stateFilter")}">
|
|
361
|
+
${["all", "in_progress", "review", "blocked", "planned", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
|
|
362
|
+
</select>
|
|
363
|
+
</div>
|
|
364
|
+
<div class="select-group">
|
|
365
|
+
<label>${t("groupBy")}</label>
|
|
366
|
+
<select data-group-mode aria-label="${t("groupBy")}">
|
|
367
|
+
${["migration", "module", "month", "state"].map((value) => `<option value="${value}" ${state.taskGroupMode === value ? "selected" : ""}>${t(`group_${value}`)}</option>`).join("")}
|
|
368
|
+
</select>
|
|
369
|
+
</div>
|
|
370
|
+
<div class="select-group">
|
|
371
|
+
<label>${t("layout")}</label>
|
|
372
|
+
<div class="layout-toggle-group">
|
|
373
|
+
<button class="layout-btn ${state.taskLayout === "list" ? "active" : ""}" data-layout="list" aria-label="${t("layoutList")}">
|
|
374
|
+
<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>
|
|
375
|
+
${t("layoutList")}
|
|
376
|
+
</button>
|
|
377
|
+
<button class="layout-btn ${state.taskLayout === "grid" ? "active" : ""}" data-layout="grid" aria-label="${t("layoutGrid")}">
|
|
378
|
+
<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>
|
|
379
|
+
${t("layoutGrid")}
|
|
380
|
+
</button>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="search-stats">
|
|
384
|
+
${t("showing")} <strong>${filteredCount}</strong> / ${(bundle.status?.tasks || []).length} ${t("tasks")}
|
|
385
|
+
</div>
|
|
386
|
+
</section>`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function taskStatsCard() {
|
|
390
|
+
const allTasks = bundle.status?.tasks || [];
|
|
391
|
+
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
392
|
+
return `<section class="sidebar-card">
|
|
393
|
+
<h3>${t("releaseHealth")}</h3>
|
|
394
|
+
<div class="stats-hero-gauge">
|
|
395
|
+
<span class="gauge-percentage">${avgCompletion}%</span>
|
|
396
|
+
<span class="gauge-label">${t("statOverall")}</span>
|
|
397
|
+
</div>
|
|
398
|
+
<div class="stats-breakdown">
|
|
399
|
+
${[
|
|
400
|
+
{ state: "in_progress", label: t("statInProgress"), colorVar: "--accent" },
|
|
401
|
+
{ state: "review", label: t("statReview"), colorVar: "--accent-2" },
|
|
402
|
+
{ state: "blocked", label: t("statBlocked"), colorVar: "--danger" },
|
|
403
|
+
{ state: "done", label: t("statDone"), colorVar: "--ok" }
|
|
404
|
+
].map(({ state, label, colorVar }) => {
|
|
405
|
+
const count = allTasks.filter(t => t.state === state).length;
|
|
406
|
+
return `<div class="stats-breakdown-row">
|
|
407
|
+
<span class="stat-label">
|
|
408
|
+
<span class="state-dot" style="background:var(${colorVar})"></span>
|
|
409
|
+
${label}
|
|
410
|
+
</span>
|
|
411
|
+
<span class="stat-value">${count}</span>
|
|
412
|
+
</div>`;
|
|
413
|
+
}).join("")}
|
|
414
|
+
</div>
|
|
415
|
+
</section>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function taskLegendCard() {
|
|
419
|
+
return `<section class="sidebar-card">
|
|
420
|
+
<h3>${t("legendTitle")}</h3>
|
|
421
|
+
<div class="legend-list">
|
|
422
|
+
<div class="legend-item">
|
|
423
|
+
<span class="badge brief ready" style="margin-top:2px">
|
|
424
|
+
<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>
|
|
425
|
+
${t("badgeBrief")}
|
|
426
|
+
</span>
|
|
427
|
+
<span>${t("legendBriefDesc")}</span>
|
|
428
|
+
</div>
|
|
429
|
+
<div class="legend-item">
|
|
430
|
+
<span class="badge map ready" style="margin-top:2px">
|
|
431
|
+
<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>
|
|
432
|
+
${t("badgeMap")}
|
|
433
|
+
</span>
|
|
434
|
+
<span>${t("legendMapDesc")}</span>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
</section>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function taskStatsBar() {
|
|
441
|
+
const allTasks = bundle.status?.tasks || [];
|
|
442
|
+
const inProgress = allTasks.filter(t => t.state === "in_progress").length;
|
|
443
|
+
const blocked = allTasks.filter(t => t.state === "blocked").length;
|
|
444
|
+
const done = allTasks.filter(t => t.state === "done").length;
|
|
445
|
+
const review = allTasks.filter(t => t.state === "review").length;
|
|
446
|
+
const avgCompletion = allTasks.length ? clampCompletion(allTasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / allTasks.length) : 0;
|
|
447
|
+
|
|
448
|
+
return `<section class="task-stats-bar">
|
|
449
|
+
<div class="stat-chip">
|
|
450
|
+
<span class="stat-value">${allTasks.length}</span>
|
|
451
|
+
<span class="stat-label">${t("statTotal")}</span>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="stat-chip in-progress">
|
|
454
|
+
<span class="stat-value">${inProgress}</span>
|
|
455
|
+
<span class="stat-label">${t("statInProgress")}</span>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="stat-chip review">
|
|
458
|
+
<span class="stat-value">${review}</span>
|
|
459
|
+
<span class="stat-label">${t("statReview")}</span>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="stat-chip blocked">
|
|
462
|
+
<span class="stat-value">${blocked}</span>
|
|
463
|
+
<span class="stat-label">${t("statBlocked")}</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="stat-chip done">
|
|
466
|
+
<span class="stat-value">${done}</span>
|
|
467
|
+
<span class="stat-label">${t("statDone")}</span>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="stat-chip completion">
|
|
470
|
+
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${avgCompletion}%"></div></div>
|
|
471
|
+
<div style="text-align:right">
|
|
472
|
+
<span class="stat-value">${avgCompletion}%</span>
|
|
473
|
+
<span class="stat-label" style="display:block;margin-top:2px">${t("statOverall")}</span>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</section>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function taskRow(task) {
|
|
480
|
+
const completion = clampCompletion(task.completion);
|
|
481
|
+
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
|
|
482
|
+
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
483
|
+
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
484
|
+
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
485
|
+
|
|
486
|
+
return `<a class="task-row-card" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
487
|
+
<div class="row-accent-bar"></div>
|
|
488
|
+
<div class="row-main">
|
|
489
|
+
<strong>${escapeHtml(task.title)}</strong>
|
|
490
|
+
<span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(taskModuleKey(task))}</span>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="row-status">${tag(task.state)}</div>
|
|
493
|
+
<div class="row-progress">
|
|
494
|
+
<div class="mini-progress-track"><div class="mini-progress-fill" style="width:${completion}%"></div></div>
|
|
495
|
+
<span class="row-pct">${completion}%</span>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="row-brief ${briefReady ? "ready" : "missing"}" title="${escapeAttr(briefLabel)}" aria-label="${escapeAttr(briefLabel)}">
|
|
498
|
+
<span class="badge brief ${briefReady ? "ready" : "missing"}">
|
|
499
|
+
<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>
|
|
500
|
+
${briefReady ? t("badgeBrief") : t("badgeBriefMissing")}
|
|
501
|
+
</span>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="row-map ${mapReady ? "ready" : "missing"}" title="${escapeAttr(mapLabel)}" aria-label="${escapeAttr(mapLabel)}">
|
|
504
|
+
<span class="badge map ${mapReady ? "ready" : "missing"}">
|
|
505
|
+
<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>
|
|
506
|
+
${mapReady ? t("badgeMap") : t("badgeMapMissing")}
|
|
507
|
+
</span>
|
|
508
|
+
</div>
|
|
509
|
+
</a>`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function taskIndex() {
|
|
513
|
+
const tasks = filteredTasks();
|
|
514
|
+
const groups = taskGroups(tasks);
|
|
515
|
+
const orderedGroups = orderedTaskGroups(groups);
|
|
516
|
+
const groupPageCount = Math.max(1, Math.ceil(orderedGroups.length / taskGroupsPerPage));
|
|
517
|
+
const groupPage = Math.min(Math.max(1, Number(state.taskGroupPage) || 1), groupPageCount);
|
|
518
|
+
const visibleGroups = orderedGroups.slice((groupPage - 1) * taskGroupsPerPage, groupPage * taskGroupsPerPage);
|
|
519
|
+
|
|
520
|
+
return `<div class="tasks-grid">
|
|
521
|
+
<div class="tasks-main stack">
|
|
522
|
+
${taskStatsBar()}
|
|
523
|
+
${visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
|
|
524
|
+
<section class="group-pager">
|
|
525
|
+
<span>${t("showingGroups")} ${visibleGroups.length ? (groupPage - 1) * taskGroupsPerPage + 1 : 0}-${Math.min(groupPage * taskGroupsPerPage, orderedGroups.length)} / ${orderedGroups.length}</span>
|
|
526
|
+
${pager("task-groups", groupPage, groupPageCount)}
|
|
253
527
|
</section>
|
|
254
528
|
</div>
|
|
255
|
-
|
|
529
|
+
<aside class="tasks-sidebar stack">
|
|
530
|
+
${taskToolbarCard(tasks.length)}
|
|
531
|
+
${taskStatsCard()}
|
|
532
|
+
${taskLegendCard()}
|
|
533
|
+
</aside>
|
|
534
|
+
</div>`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function orderedTaskGroups(groups) {
|
|
538
|
+
const rank = (group) => {
|
|
539
|
+
if (group.startsWith("module:")) return 2;
|
|
540
|
+
if (group.startsWith("state:")) return 2;
|
|
541
|
+
if (group.startsWith("month:")) return 2;
|
|
542
|
+
if (group === "active") return 0;
|
|
543
|
+
if (group === "brief-ready") return 1;
|
|
544
|
+
if (group.startsWith("legacy:")) return 2;
|
|
545
|
+
if (group === "unknown") return 3;
|
|
546
|
+
return 4;
|
|
547
|
+
};
|
|
548
|
+
return Object.entries(groups).sort(([left], [right]) => rank(left) - rank(right) || left.localeCompare(right));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function taskGroups(tasks) {
|
|
552
|
+
if (state.taskGroupMode === "module") {
|
|
553
|
+
return groupBy(tasks, (task) => `module:${taskModuleKey(task)}`);
|
|
554
|
+
}
|
|
555
|
+
if (state.taskGroupMode === "month") {
|
|
556
|
+
return groupBy(tasks, (task) => {
|
|
557
|
+
const match = task.shortId?.match(/^(\d{4}-\d{2})/);
|
|
558
|
+
return match ? `month:${match[1]}` : "month:unknown";
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (state.taskGroupMode === "state") {
|
|
562
|
+
return groupBy(tasks, (task) => `state:${task.state || "unknown"}`);
|
|
563
|
+
}
|
|
564
|
+
return groupBy(tasks, (task) => {
|
|
565
|
+
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)) return "active";
|
|
566
|
+
if (task.briefSource === "standalone") return "brief-ready";
|
|
567
|
+
const match = task.shortId?.match(/^(\d{4}-\d{2})/);
|
|
568
|
+
return match ? `legacy:${match[1]}` : task.state || "unknown";
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function taskGroup(group, tasks) {
|
|
573
|
+
const pageCount = Math.max(1, Math.ceil(tasks.length / taskPageSize));
|
|
574
|
+
const page = Math.min(Math.max(1, Number(state.taskPageByGroup[group]) || 1), pageCount);
|
|
575
|
+
const start = (page - 1) * taskPageSize;
|
|
576
|
+
const visibleTasks = tasks.slice(start, start + taskPageSize);
|
|
577
|
+
const avgCompletion = tasks.length ? clampCompletion(tasks.reduce((sum, task) => sum + clampCompletion(task.completion), 0) / tasks.length) : 0;
|
|
578
|
+
|
|
579
|
+
const isGrid = state.taskLayout === "grid";
|
|
580
|
+
const layoutClass = isGrid ? "task-card-grid" : "task-list";
|
|
581
|
+
const itemRenderer = isGrid ? taskCard : taskRow;
|
|
582
|
+
const listHeader = isGrid ? "" : `<div class="task-list-header">
|
|
583
|
+
<div class="col-main">${t("columnTask")}</div>
|
|
584
|
+
<div class="col-status">${t("columnState")}</div>
|
|
585
|
+
<div class="col-progress">${t("columnCompletion")}</div>
|
|
586
|
+
<div class="col-brief">${t("columnBrief")}</div>
|
|
587
|
+
<div class="col-map">${t("badgeMap")}</div>
|
|
588
|
+
</div>`;
|
|
589
|
+
|
|
590
|
+
return `<section class="task-group">
|
|
591
|
+
<div class="section-head">
|
|
592
|
+
<div>
|
|
593
|
+
<h2>${taskGroupLabel(group)}</h2>
|
|
594
|
+
<p class="subtle">${t("showing")} ${Math.min(start + 1, tasks.length)}-${Math.min(start + visibleTasks.length, tasks.length)} / ${tasks.length}</p>
|
|
595
|
+
</div>
|
|
596
|
+
<div class="group-actions">
|
|
597
|
+
<div class="group-progress" aria-label="${escapeAttr(t("groupCompletion"))}">
|
|
598
|
+
<div class="group-progress-track"><div class="group-progress-fill" style="width:${avgCompletion}%"></div></div>
|
|
599
|
+
<span>${avgCompletion}%</span>
|
|
600
|
+
</div>
|
|
601
|
+
${pager("task", page, pageCount, group)}
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
<div class="${layoutClass}">
|
|
605
|
+
${listHeader}
|
|
606
|
+
${visibleTasks.map(itemRenderer).join("")}
|
|
607
|
+
</div>
|
|
608
|
+
</section>`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function taskCard(task) {
|
|
612
|
+
const completion = clampCompletion(task.completion);
|
|
613
|
+
const stateColor = stateToColorVar(task.state);
|
|
614
|
+
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
|
|
615
|
+
const mapReady = !!taskDocument(task, "visual_map.md");
|
|
616
|
+
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
|
|
617
|
+
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
|
|
618
|
+
|
|
619
|
+
return `<a class="task-card" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateColor})">
|
|
620
|
+
<div class="card-header">
|
|
621
|
+
<span class="card-id">${escapeHtml(task.id)}</span>
|
|
622
|
+
${tag(task.state)}
|
|
623
|
+
</div>
|
|
624
|
+
<h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
|
|
625
|
+
<div class="card-meta">
|
|
626
|
+
<span class="meta-module" title="${escapeAttr(taskModuleKey(task))}">
|
|
627
|
+
<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>
|
|
628
|
+
${escapeHtml(taskModuleKey(task))}
|
|
629
|
+
</span>
|
|
630
|
+
</div>
|
|
631
|
+
<div class="card-progress">
|
|
632
|
+
<div class="card-progress-track"><div class="card-progress-fill" style="width:${completion}%"></div></div>
|
|
633
|
+
<span class="progress-pct">${completion}%</span>
|
|
634
|
+
</div>
|
|
635
|
+
<div class="card-badges">
|
|
636
|
+
<span class="badge brief ${briefReady ? "ready" : "missing"}" title="${escapeAttr(briefLabel)}">
|
|
637
|
+
<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>
|
|
638
|
+
${briefReady ? t("badgeBrief") : t("badgeBriefMissing")}
|
|
639
|
+
</span>
|
|
640
|
+
<span class="badge map ${mapReady ? "ready" : "missing"}" title="${escapeAttr(mapLabel)}">
|
|
641
|
+
<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>
|
|
642
|
+
${mapReady ? t("badgeMap") : t("badgeMapMissing")}
|
|
643
|
+
</span>
|
|
644
|
+
</div>
|
|
645
|
+
</a>`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function taskGroupLabel(group) {
|
|
649
|
+
if (group === "active") return t("activeCurrent");
|
|
650
|
+
if (group === "brief-ready") return t("briefReadyGroup");
|
|
651
|
+
if (group.startsWith("legacy:")) return `${t("legacyMonth")} ${group.slice("legacy:".length)}`;
|
|
652
|
+
if (group.startsWith("module:")) return `${t("inferredModule")} · ${group.slice("module:".length)}`;
|
|
653
|
+
if (group.startsWith("month:")) return `${t("legacyMonth")} ${group.slice("month:".length)}`;
|
|
654
|
+
if (group.startsWith("state:")) return `${t("columnState")} · ${label(group.slice("state:".length))}`;
|
|
655
|
+
return label(group);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function filteredTasks() {
|
|
659
|
+
const query = state.query.trim().toLowerCase();
|
|
660
|
+
return (bundle.status?.tasks || []).filter((task) => {
|
|
661
|
+
const stateMatch = state.taskState === "all" || task.state === state.taskState;
|
|
662
|
+
if (!stateMatch) return false;
|
|
663
|
+
if (!query) return true;
|
|
664
|
+
return [task.id, task.shortId, task.title, task.module, task.inferredModule, task.classificationSource, task.classificationBucket, task.state].some((value) => String(value || "").toLowerCase().includes(query));
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function taskModuleKey(task) {
|
|
669
|
+
return task.module || task.inferredModule || "legacy-unclassified";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function taskDetail(route) {
|
|
673
|
+
const taskId = route.id;
|
|
674
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
675
|
+
if (!task) return `<main>${emptyState(t("taskNotFound"))}</main>`;
|
|
676
|
+
return `<main class="task-detail">
|
|
677
|
+
<nav class="crumbs"><a href="#/tasks">${t("taskIndex")}</a><span>/</span><span>${escapeHtml(task.id)}</span></nav>
|
|
678
|
+
<section class="detail-hero">
|
|
679
|
+
<div>
|
|
680
|
+
<p class="eyebrow">${t("taskVisibility")}</p>
|
|
681
|
+
<h2>${escapeHtml(task.title)}</h2>
|
|
682
|
+
<p>${escapeHtml(task.path)}</p>
|
|
683
|
+
</div>
|
|
684
|
+
<div class="detail-score">${task.completion}%</div>
|
|
685
|
+
</section>
|
|
686
|
+
${taskStateSummary(task)}
|
|
687
|
+
${phaseTimeline(task)}
|
|
688
|
+
<section class="detail-grid">
|
|
689
|
+
<article class="detail-main">
|
|
690
|
+
${taskDocumentLibrary(task, route.doc)}
|
|
691
|
+
</article>
|
|
692
|
+
<aside class="detail-side">
|
|
693
|
+
${reviewActionPanel(task)}
|
|
694
|
+
${openFindings(task)}
|
|
695
|
+
${evidenceList(task)}
|
|
696
|
+
${documentTabs(task)}
|
|
697
|
+
</aside>
|
|
698
|
+
</section>
|
|
699
|
+
</main>`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function taskStateSummary(task) {
|
|
703
|
+
return `<section class="task-state-summary">
|
|
704
|
+
<div>
|
|
705
|
+
<span>${t("legacyState")}</span>
|
|
706
|
+
${tag(task.state)}
|
|
707
|
+
</div>
|
|
708
|
+
<div>
|
|
709
|
+
<span>${t("lifecycleState")}</span>
|
|
710
|
+
${tag(task.lifecycleState || "unknown")}
|
|
711
|
+
</div>
|
|
712
|
+
<div>
|
|
713
|
+
<span>${t("reviewStatus")}</span>
|
|
714
|
+
${tag(task.reviewStatus || "missing")}
|
|
715
|
+
</div>
|
|
716
|
+
<div>
|
|
717
|
+
<span>${t("closeoutStatus")}</span>
|
|
718
|
+
${tag(task.closeoutStatus || "missing")}
|
|
719
|
+
</div>
|
|
256
720
|
</section>`;
|
|
257
721
|
}
|
|
258
722
|
|
|
259
|
-
function
|
|
260
|
-
return `<section class="
|
|
261
|
-
<h2>${t("
|
|
262
|
-
|
|
263
|
-
|
|
723
|
+
function phaseTimeline(task) {
|
|
724
|
+
return `<section class="phase-timeline">
|
|
725
|
+
<h2>${t("phaseTimeline")}</h2>
|
|
726
|
+
${(task.phases || []).map((phase) => `<div class="phase-step ${phase.state}">
|
|
727
|
+
<strong>${escapeHtml(phase.id)}</strong>
|
|
728
|
+
<span>${phase.completion}%</span>
|
|
729
|
+
<p>${escapeHtml(phase.output || phase.blockingRisk || phase.state)}</p>
|
|
730
|
+
${progressBar(phase.completion)}
|
|
731
|
+
</div>`).join("") || emptyState(t("noPhaseData"))}
|
|
264
732
|
</section>`;
|
|
265
733
|
}
|
|
266
734
|
|
|
267
|
-
function
|
|
268
|
-
|
|
735
|
+
function taskDocSection(task, fileName, title, required) {
|
|
736
|
+
const doc = taskDocument(task, fileName);
|
|
737
|
+
if (!doc && !required) return "";
|
|
738
|
+
return `<section class="doc-section">
|
|
739
|
+
<div class="section-head"><h2>${escapeHtml(title)}</h2>${doc ? `<button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button>` : ""}</div>
|
|
740
|
+
<div class="markdown">${doc ? window.HarnessMarkdown.render(doc.content, state.renderMode) : generatedBrief(task)}</div>
|
|
741
|
+
</section>`;
|
|
269
742
|
}
|
|
270
743
|
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
744
|
+
function taskDocumentLibrary(task, selectedTab) {
|
|
745
|
+
const docs = orderedTaskDocuments(task);
|
|
746
|
+
if (!docs.length) return taskDocSection(task, "brief.md", t("brief"), true);
|
|
747
|
+
const selectedKey = docs.some((doc) => doc.key === selectedTab) ? selectedTab : defaultTaskDocumentKey(task, docs);
|
|
748
|
+
return `<section class="doc-library">
|
|
749
|
+
<div class="section-head">
|
|
750
|
+
<div>
|
|
751
|
+
<p class="eyebrow">${t("taskDocuments")}</p>
|
|
752
|
+
<h2>${escapeHtml(t("sourceDocuments"))}</h2>
|
|
753
|
+
</div>
|
|
754
|
+
<button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button>
|
|
755
|
+
</div>
|
|
756
|
+
<div class="doc-accordion-list">
|
|
757
|
+
${docs.map((item) => documentAccordion(item, item.key === selectedKey)).join("")}
|
|
758
|
+
</div>
|
|
278
759
|
</section>`;
|
|
279
760
|
}
|
|
280
761
|
|
|
281
|
-
function
|
|
282
|
-
|
|
762
|
+
function orderedTaskDocuments(task) {
|
|
763
|
+
const docs = taskDocTabs
|
|
764
|
+
.map(([key, file]) => {
|
|
765
|
+
const doc = taskDocument(task, file);
|
|
766
|
+
if (doc) return { key, file, title: t(key), path: doc.path, content: doc.content };
|
|
767
|
+
if (key === "brief") return { key, file, title: t(key), path: `${task.path}/brief.md`, content: generatedBrief(task), generated: true };
|
|
768
|
+
return null;
|
|
769
|
+
})
|
|
770
|
+
.filter(Boolean);
|
|
771
|
+
const priority = taskDocumentPriority(task);
|
|
772
|
+
const rank = new Map(priority.map((key, index) => [key, index]));
|
|
773
|
+
return docs.sort((a, b) => (rank.get(a.key) ?? 99) - (rank.get(b.key) ?? 99));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function taskDocumentPriority(task) {
|
|
777
|
+
const stateName = task?.state || "";
|
|
778
|
+
const lifecycle = task?.lifecycleState || "";
|
|
779
|
+
if (stateName === "review" || ["in_review", "review-blocked"].includes(lifecycle)) {
|
|
780
|
+
return ["walkthrough", "review", "findings", "visualMap", "progress", "brief", "taskPlan", "strategy", "legacyRoadmap", "references", "artifacts"];
|
|
781
|
+
}
|
|
782
|
+
if (stateName === "in_progress" || lifecycle === "active" || stateName === "blocked") {
|
|
783
|
+
return ["progress", "visualMap", "brief", "taskPlan", "strategy", "findings", "review", "walkthrough", "references", "artifacts", "legacyRoadmap"];
|
|
784
|
+
}
|
|
785
|
+
if (stateName === "done" || ["closing", "closed"].includes(lifecycle)) {
|
|
786
|
+
return ["walkthrough", "progress", "review", "findings", "visualMap", "brief", "taskPlan", "strategy", "references", "artifacts", "legacyRoadmap"];
|
|
787
|
+
}
|
|
788
|
+
return ["brief", "taskPlan", "visualMap", "strategy", "progress", "findings", "review", "walkthrough", "references", "artifacts", "legacyRoadmap"];
|
|
283
789
|
}
|
|
284
790
|
|
|
285
|
-
function
|
|
791
|
+
function defaultTaskDocumentKey(task, docs) {
|
|
792
|
+
const priority = taskDocumentPriority(task);
|
|
793
|
+
return priority.find((key) => docs.some((doc) => doc.key === key)) || docs[0]?.key || "brief";
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function documentAccordion(item, open) {
|
|
797
|
+
return `<details class="doc-accordion" ${open ? "open" : ""}>
|
|
798
|
+
<summary>
|
|
799
|
+
<span>${escapeHtml(item.title)}</span>
|
|
800
|
+
<small>${escapeHtml(item.generated ? t("generatedFallback") : item.path)}</small>
|
|
801
|
+
</summary>
|
|
802
|
+
<div class="markdown">${window.HarnessMarkdown.render(item.content, state.renderMode)}</div>
|
|
803
|
+
</details>`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function documentTabs(task) {
|
|
807
|
+
const docs = orderedTaskDocuments(task);
|
|
808
|
+
return `<section class="side-panel">
|
|
809
|
+
<h3>${t("sourceDocuments")}</h3>
|
|
810
|
+
${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>`}
|
|
811
|
+
</section>`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function selectedSourceDocument(task, tab) {
|
|
815
|
+
if (!tab) return "";
|
|
816
|
+
const match = taskDocTabs.find(([key]) => key === tab);
|
|
817
|
+
if (!match) return "";
|
|
818
|
+
const doc = taskDocument(task, match[1]);
|
|
819
|
+
if (!doc) return "";
|
|
820
|
+
return `<section class="doc-section selected-source">
|
|
821
|
+
<div class="section-head"><h2>${t("selectedSource")} · ${t(match[0])}</h2><button data-render-toggle>${state.renderMode === "rendered" ? t("source") : t("rendered")}</button></div>
|
|
822
|
+
<div class="markdown">${window.HarnessMarkdown.render(doc.content, state.renderMode)}</div>
|
|
823
|
+
</section>`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function openFindings(task) {
|
|
827
|
+
const risks = task.risks || [];
|
|
828
|
+
return `<section class="side-panel">
|
|
829
|
+
<h3>${t("openFindings")}</h3>
|
|
830
|
+
${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>`}
|
|
831
|
+
</section>`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function reviewActionPanel(task) {
|
|
835
|
+
if (!canUseWorkbenchAction("review-complete")) return "";
|
|
836
|
+
if (!isTaskInReviewStage(task)) return "";
|
|
837
|
+
const blocking = task.reviewStatus === "blocked-open-findings" || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
|
|
838
|
+
const confirmed = task.reviewStatus === "confirmed";
|
|
839
|
+
if (confirmed) {
|
|
840
|
+
return `<section class="side-panel review-actions">
|
|
841
|
+
<h3>${t("reviewActions")}</h3>
|
|
842
|
+
<p>${escapeHtml(t("reviewAlreadyConfirmed"))}</p>
|
|
843
|
+
</section>`;
|
|
844
|
+
}
|
|
845
|
+
const missingWalkthrough = task.budget !== "simple" && !task.walkthroughPath;
|
|
846
|
+
const disabled = blocking || missingWalkthrough;
|
|
847
|
+
const message = missingWalkthrough ? t("reviewWalkthroughRequired") : blocking ? t("reviewBlocked") : t("reviewWorkbenchReady");
|
|
848
|
+
return `<section class="side-panel review-actions">
|
|
849
|
+
<h3>${t("reviewActions")}</h3>
|
|
850
|
+
<p>${escapeHtml(message)}</p>
|
|
851
|
+
<label class="review-check">
|
|
852
|
+
<input type="checkbox" data-review-confirm-check="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>
|
|
853
|
+
<span>${t("reviewConfirmChecklist")}</span>
|
|
854
|
+
</label>
|
|
855
|
+
<input data-review-confirm-text="${escapeAttr(task.id)}" value="" placeholder="${escapeAttr(task.shortId || task.id)}" ${disabled ? "disabled" : ""}>
|
|
856
|
+
<button data-review-complete="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${t("confirmReviewComplete")}</button>
|
|
857
|
+
<div class="review-result" data-review-result="${escapeAttr(task.id)}"></div>
|
|
858
|
+
</section>`;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function isTaskInReviewStage(task) {
|
|
862
|
+
const state = task?.state || "";
|
|
863
|
+
const lifecycle = task?.lifecycleState || "";
|
|
864
|
+
if (["not_started", "planned", "in_progress"].includes(state)) return false;
|
|
865
|
+
return state === "review" || ["in_review", "review-blocked"].includes(lifecycle);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function evidenceList(task) {
|
|
869
|
+
const evidence = task.evidence || [];
|
|
870
|
+
return `<section class="side-panel">
|
|
871
|
+
<h3>${t("evidence")}</h3>
|
|
872
|
+
${evidence.map((item) => `<p><strong>${escapeHtml(item.type || "evidence")}</strong> ${escapeHtml(item.summary || "")}</p>`).join("") || `<p>${t("noEvidence")}</p>`}
|
|
873
|
+
</section>`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function modulesView(moduleId = "") {
|
|
286
877
|
const graph = bundle.graph || { nodes: [], edges: [] };
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
878
|
+
const explicitModules = (graph.nodes || []).filter((node) => node.type === "module");
|
|
879
|
+
const moduleMap = new Map(explicitModules.map((module) => [module.id.replace(/^module:/, ""), module]));
|
|
880
|
+
for (const task of bundle.status?.tasks || []) {
|
|
881
|
+
const key = taskModuleKey(task);
|
|
882
|
+
if (!moduleMap.has(key)) moduleMap.set(key, { id: `module:${key}`, type: "module", label: key, state: task.classificationSource || "inferred" });
|
|
883
|
+
}
|
|
884
|
+
const modules = [...moduleMap.values()];
|
|
885
|
+
return `<main class="stack">
|
|
886
|
+
<section class="module-grid">
|
|
887
|
+
${modules.map((module) => moduleCard(module)).join("") || emptyState(t("noModules"))}
|
|
888
|
+
</section>
|
|
889
|
+
</main>`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function moduleTaskRow(task) {
|
|
893
|
+
const dotClass = /fail|blocked|open/i.test(task.state) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(task.state) ? "state-warn" : "state-pass";
|
|
894
|
+
return `<a class="module-task-row" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}">
|
|
895
|
+
<div class="module-task-left">
|
|
896
|
+
<i class="module-task-dot ${dotClass}" title="${escapeAttr(task.state)}"></i>
|
|
897
|
+
<span class="module-task-title">${escapeHtml(task.title)}</span>
|
|
898
|
+
</div>
|
|
899
|
+
<span class="module-task-pct">${task.completion}%</span>
|
|
900
|
+
</a>`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function moduleCard(module) {
|
|
904
|
+
const moduleKey = module.id.replace(/^module:/, "");
|
|
905
|
+
const tasks = (bundle.status?.tasks || []).filter((task) => taskModuleKey(task) === moduleKey);
|
|
906
|
+
|
|
907
|
+
// Inline Pagination
|
|
908
|
+
state.modulePages = state.modulePages || {};
|
|
909
|
+
const currentPage = state.modulePages[moduleKey] || 1;
|
|
910
|
+
const pageCount = Math.ceil(tasks.length / 8) || 1;
|
|
911
|
+
const visibleTasks = tasks.slice((currentPage - 1) * 8, currentPage * 8);
|
|
912
|
+
|
|
913
|
+
const brief = findDocument(`TARGET:docs/09-PLANNING/MODULES/${moduleKey}/brief.md`);
|
|
914
|
+
|
|
915
|
+
let pagerHtml = "";
|
|
916
|
+
if (tasks.length > 8) {
|
|
917
|
+
pagerHtml = `<div class="module-pager">
|
|
918
|
+
<button ${currentPage <= 1 ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage - 1})">${t("prevPage")}</button>
|
|
919
|
+
<span>${currentPage} / ${pageCount}</span>
|
|
920
|
+
<button ${currentPage >= pageCount ? "disabled" : ""} onclick="window.setModulePage('${escapeAttr(moduleKey)}', ${currentPage + 1})">${t("nextPage")}</button>
|
|
921
|
+
</div>`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return `<article class="module-card">
|
|
925
|
+
<div class="card-head"><h2>${escapeHtml(module.label || moduleKey)}</h2>${tag(module.state || "unknown")}</div>
|
|
926
|
+
<div class="markdown">${brief ? window.HarnessMarkdown.render(brief.content, "rendered") : `<p>${t("moduleBriefMissing")}</p>`}</div>
|
|
927
|
+
<h3>${t("moduleTasks")} · ${tasks.length}</h3>
|
|
928
|
+
<div class="module-task-list">
|
|
929
|
+
${visibleTasks.map(moduleTaskRow).join("") || `<p>${t("noModuleTasks")}</p>`}
|
|
930
|
+
</div>
|
|
931
|
+
${pagerHtml}
|
|
932
|
+
</article>`;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function reviewQueue() {
|
|
936
|
+
const tasks = reviewQueueTasks();
|
|
937
|
+
const ready = tasks.filter((task) => task.reviewStatus !== "blocked-open-findings" && task.reviewStatus !== "confirmed").length;
|
|
938
|
+
const blocked = tasks.filter((task) => task.reviewStatus === "blocked-open-findings").length;
|
|
939
|
+
const confirmed = tasks.filter((task) => task.reviewStatus === "confirmed").length;
|
|
940
|
+
return `<div class="dashboard-grid review-queue-page">
|
|
941
|
+
<main class="dashboard-main stack">
|
|
942
|
+
<section class="flow-panel">
|
|
943
|
+
<div class="section-head">
|
|
944
|
+
<div>
|
|
945
|
+
<p class="eyebrow">${t("review")}</p>
|
|
946
|
+
<h2>${t("reviewQueue")}</h2>
|
|
947
|
+
<p class="subtle">${t("reviewQueueSubtitle")}</p>
|
|
948
|
+
</div>
|
|
949
|
+
<span class="subtle">${ready}/${tasks.length} ${t("reviewReady")}</span>
|
|
950
|
+
</div>
|
|
951
|
+
<div class="task-card-grid review-queue-grid">
|
|
952
|
+
${tasks.map(reviewQueueCard).join("") || emptyState(t("noReviewTasks"))}
|
|
953
|
+
</div>
|
|
954
|
+
</section>
|
|
955
|
+
</main>
|
|
956
|
+
<aside class="dashboard-sidebar stack">
|
|
957
|
+
<section class="side-panel review-queue-summary">
|
|
958
|
+
<h3>${t("reviewQueue")}</h3>
|
|
959
|
+
<div class="review-queue-stats">
|
|
960
|
+
${metric(t("reviewReady"), ready)}
|
|
961
|
+
${metric(t("reviewBlockedQueue"), blocked)}
|
|
962
|
+
${metric(t("reviewConfirmedQueue"), confirmed)}
|
|
963
|
+
</div>
|
|
964
|
+
</section>
|
|
965
|
+
<section class="side-panel">
|
|
966
|
+
<h3>${t("review")}</h3>
|
|
967
|
+
<p>${escapeHtml(t("reviewQueueSubtitle"))}</p>
|
|
968
|
+
</section>
|
|
969
|
+
</aside>
|
|
970
|
+
</div>`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function reviewQueueTasks() {
|
|
974
|
+
return (bundle.status?.tasks || [])
|
|
975
|
+
.filter(isTaskInReviewStage)
|
|
976
|
+
.sort((left, right) => reviewSortKey(left).localeCompare(reviewSortKey(right)));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function reviewSortKey(task) {
|
|
980
|
+
const rank = task.reviewStatus === "blocked-open-findings" ? "0" : task.reviewStatus === "confirmed" ? "2" : "1";
|
|
981
|
+
return `${rank}:${task.id}`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function reviewQueueCard(task) {
|
|
985
|
+
const openMaterial = (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
|
|
986
|
+
return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(task.state)})">
|
|
987
|
+
<div class="card-header">
|
|
988
|
+
<span class="card-id">${escapeHtml(task.id)}</span>
|
|
989
|
+
${tag(task.reviewStatus || "missing")}
|
|
990
|
+
</div>
|
|
991
|
+
<h4 class="card-title" title="${escapeAttr(task.title)}">${escapeHtml(task.title)}</h4>
|
|
992
|
+
<div class="card-meta">
|
|
993
|
+
<span>${tag(task.lifecycleState || "unknown")}</span>
|
|
994
|
+
<span>${tag(task.closeoutStatus || "missing")}</span>
|
|
995
|
+
<span>${openMaterial} ${t("openFindings")}</span>
|
|
996
|
+
</div>
|
|
997
|
+
<p class="subtle">${escapeHtml(firstUsefulLine(task.summary || task.briefText || ""))}</p>
|
|
998
|
+
<div class="review-queue-actions">
|
|
999
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}">${t("fullView")}</a>
|
|
1000
|
+
<button data-open-drawer="${escapeAttr(task.id)}">${t("viewDetails")}</button>
|
|
1001
|
+
</div>
|
|
1002
|
+
${reviewActionPanel(task)}
|
|
1003
|
+
</article>`;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function firstUsefulLine(text) {
|
|
1007
|
+
return String(text || "")
|
|
1008
|
+
.split(/\n+/)
|
|
1009
|
+
.map((line) => line.trim())
|
|
1010
|
+
.filter(Boolean)[0] || "";
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function migrationPanel() {
|
|
1014
|
+
const advice = warningQueue();
|
|
1015
|
+
const missingBriefs = advice.filter((warning) => warning.type === "missing-brief").length;
|
|
1016
|
+
if (advice.length === 0 && missingBriefs === 0) return "";
|
|
1017
|
+
const groups = groupBy(advice, (item) => item.category || "Advice");
|
|
1018
|
+
const categories = Object.entries(groups).slice(0, 6);
|
|
1019
|
+
return `<section class="migration-panel">
|
|
1020
|
+
<div class="section-head">
|
|
1021
|
+
<div>
|
|
1022
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1023
|
+
<h2>${t("migrationWorkbench")}</h2>
|
|
1024
|
+
</div>
|
|
1025
|
+
<span>${advice.length} ${t("advice")} · ${missingBriefs} ${t("briefMissing")}</span>
|
|
1026
|
+
</div>
|
|
1027
|
+
<div class="migration-grid">
|
|
1028
|
+
${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("")}
|
|
1029
|
+
${missingBriefs > 0 ? `<div><strong>${t("visibilityLayer")}</strong><p>${missingBriefs} ${t("missingBriefs")}</p></div>` : ""}
|
|
1030
|
+
</div>
|
|
1031
|
+
${migrationWarningWorkbench(advice)}
|
|
297
1032
|
</section>`;
|
|
298
1033
|
}
|
|
299
1034
|
|
|
300
|
-
function
|
|
301
|
-
|
|
1035
|
+
function migrationWarningWorkbench(advice) {
|
|
1036
|
+
const groups = groupBy(advice, (item) => item.category || "Advice");
|
|
1037
|
+
const filters = ["all", ...Object.keys(groups).sort(), ...new Set(advice.map((item) => item.type).filter(Boolean)), "active-task-contracts", "strict-cutover"];
|
|
1038
|
+
const filtered = state.warningFilter === "all" ? advice : advice.filter((item) => (item.category || "Advice") === state.warningFilter || item.phase === state.warningFilter || item.type === state.warningFilter);
|
|
1039
|
+
const pageCount = Math.max(1, Math.ceil(filtered.length / warningPageSize));
|
|
1040
|
+
const page = Math.min(Math.max(1, Number(state.warningPage) || 1), pageCount);
|
|
1041
|
+
const visible = filtered.slice((page - 1) * warningPageSize, page * warningPageSize);
|
|
1042
|
+
return `<div class="warning-workbench">
|
|
1043
|
+
<div class="warning-toolbar">
|
|
1044
|
+
<select data-warning-filter-select aria-label="${t("warningFilter")}">
|
|
1045
|
+
${filters.map((filter) => `<option value="${escapeAttr(filter)}" ${state.warningFilter === filter ? "selected" : ""}>${filter === "all" ? t("allWarnings") : escapeHtml(filter)}</option>`).join("")}
|
|
1046
|
+
</select>
|
|
1047
|
+
<span>${t("showing")} ${visible.length ? (page - 1) * warningPageSize + 1 : 0}-${Math.min(page * warningPageSize, filtered.length)} / ${filtered.length}</span>
|
|
1048
|
+
${pager("warning", page, pageCount)}
|
|
1049
|
+
</div>
|
|
1050
|
+
<div class="warning-list">
|
|
1051
|
+
${visible.map(warningRow).join("") || emptyState(t("noWarnings"))}
|
|
1052
|
+
</div>
|
|
1053
|
+
</div>`;
|
|
302
1054
|
}
|
|
303
1055
|
|
|
304
|
-
function
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
1056
|
+
function migrationSummaryPanel() {
|
|
1057
|
+
const advice = warningQueue();
|
|
1058
|
+
const summary = bundle.status?.summary || {};
|
|
1059
|
+
if (advice.length === 0 && summary.fullCutoverEligible) {
|
|
1060
|
+
return `<section class="migration-panel">
|
|
1061
|
+
<div class="section-head">
|
|
1062
|
+
<div>
|
|
1063
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1064
|
+
<h2>${t("fullCutover")}</h2>
|
|
1065
|
+
</div>
|
|
1066
|
+
<span>${t("ready")}</span>
|
|
1067
|
+
</div>
|
|
1068
|
+
${emptyState(t("noWarnings"))}
|
|
1069
|
+
</section>`;
|
|
308
1070
|
}
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1071
|
+
const cards = [
|
|
1072
|
+
[t("advice"), advice.length],
|
|
1073
|
+
[t("legacyVisualOnly"), summary.legacyVisualOnlyCount || 0],
|
|
1074
|
+
[t("weakBrief"), summary.weakBriefCount || 0],
|
|
1075
|
+
[t("blockers"), bundle.status?.checkState?.failures || 0],
|
|
1076
|
+
];
|
|
1077
|
+
return `<section class="migration-panel">
|
|
1078
|
+
<div class="section-head">
|
|
1079
|
+
<div>
|
|
1080
|
+
<p class="eyebrow">${t("migration")}</p>
|
|
1081
|
+
<h2>${t("migrationSummary")}</h2>
|
|
1082
|
+
</div>
|
|
1083
|
+
<a href="#/tasks">${t("openTaskIndex")}</a>
|
|
1084
|
+
</div>
|
|
1085
|
+
<div class="migration-grid">
|
|
1086
|
+
${cards.map(([title, count]) => `<a href="#/tasks"><strong>${escapeHtml(title)}</strong><p>${count} ${t("items")}</p></a>`).join("")}
|
|
1087
|
+
</div>
|
|
1088
|
+
${migrationWarningWorkbench(advice)}
|
|
1089
|
+
</section>`;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function warningRow(warning) {
|
|
1093
|
+
const affected = warning.affectedPaths?.length ? warning.affectedPaths.join(", ") : warning.affected;
|
|
1094
|
+
return `<article class="warning-row">
|
|
1095
|
+
<div>
|
|
1096
|
+
<strong>${escapeHtml(warning.id)} · ${escapeHtml(warning.title)}</strong>
|
|
1097
|
+
<p>${escapeHtml(affected || "project")}</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
<span>${tag(warning.priority || warning.severity)}</span>
|
|
1100
|
+
<span>${escapeHtml(warning.status || "open")}</span>
|
|
1101
|
+
<span>${escapeHtml(warning.fixability || "manual")}</span>
|
|
1102
|
+
<span>${escapeHtml(warning.phase || "triage")}</span>
|
|
1103
|
+
<p>${escapeHtml(warning.requiredAction || warning.detail || "")} · ${t("confidence")}: ${escapeHtml(warning.confidence || "medium")}</p>
|
|
1104
|
+
</article>`;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function warningQueue() {
|
|
1108
|
+
const adoptionWarnings = (bundle.adoption?.warnings || []).map((warning) => ({ ...warning }));
|
|
1109
|
+
const existingBriefPaths = new Set(adoptionWarnings.filter((warning) => warning.type === "missing-brief").map((warning) => warning.affected));
|
|
1110
|
+
const briefWarnings = (bundle.status?.tasks || [])
|
|
1111
|
+
.filter((task) => task.briefSource !== "standalone")
|
|
1112
|
+
.filter((task) => !existingBriefPaths.has(task.path))
|
|
1113
|
+
.map((task, index) => ({
|
|
1114
|
+
id: `VB-${String(index + 1).padStart(3, "0")}`,
|
|
1115
|
+
category: "Visibility Layer",
|
|
1116
|
+
type: "missing-brief",
|
|
1117
|
+
scope: "task",
|
|
1118
|
+
priority: (typeof isActiveTaskState === "function" && isActiveTaskState(task.state)) || ["planned", "not_started"].includes(task.state) ? "P2" : "P3",
|
|
1119
|
+
phase: "active-task-contracts",
|
|
1120
|
+
fixability: "guided",
|
|
1121
|
+
status: "open",
|
|
1122
|
+
confidence: task.state === "unknown" ? "medium" : "high",
|
|
1123
|
+
severity: "advice",
|
|
1124
|
+
title: t("visibilityBriefMissing"),
|
|
1125
|
+
affected: task.path,
|
|
1126
|
+
affectedPaths: [task.path],
|
|
1127
|
+
requiredAction: t("addVisibilityBrief"),
|
|
1128
|
+
detail: `${task.id} ${task.title}`,
|
|
1129
|
+
}));
|
|
1130
|
+
return [...adoptionWarnings, ...briefWarnings].sort(warningSort);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function warningSort(left, right) {
|
|
1134
|
+
const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
1135
|
+
const fixRank = { template: 0, guided: 1, "human-evidence": 2, decision: 3, manual: 4 };
|
|
1136
|
+
return (priorityRank[left.priority] ?? 9) - (priorityRank[right.priority] ?? 9)
|
|
1137
|
+
|| (fixRank[left.fixability] ?? 9) - (fixRank[right.fixability] ?? 9)
|
|
1138
|
+
|| String(left.phase || "").localeCompare(String(right.phase || ""))
|
|
1139
|
+
|| String(left.id || "").localeCompare(String(right.id || ""));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function pager(kind, page, pageCount, group = "") {
|
|
1143
|
+
if (pageCount <= 1) return `<span class="pager muted">${page}/${pageCount}</span>`;
|
|
1144
|
+
const groupAttr = group ? ` data-page-group="${escapeAttr(group)}"` : "";
|
|
1145
|
+
return `<div class="pager">
|
|
1146
|
+
<button data-page-kind="${kind}" data-page="${page - 1}"${groupAttr} ${page <= 1 ? "disabled" : ""}>${t("prevPage")}</button>
|
|
1147
|
+
<span>${page}/${pageCount}</span>
|
|
1148
|
+
<button data-page-kind="${kind}" data-page="${page + 1}"${groupAttr} ${page >= pageCount ? "disabled" : ""}>${t("nextPage")}</button>
|
|
1149
|
+
</div>`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function lessonPanel() {
|
|
1153
|
+
const lessons = (bundle.tables?.tables || [])
|
|
1154
|
+
.filter((table) => table.kind === "lessons-ssot")
|
|
1155
|
+
.flatMap((table) => table.rows);
|
|
1156
|
+
return `<section class="lesson-panel">
|
|
1157
|
+
<div class="section-head"><h2>${t("lessons")}</h2><span>${lessons.length}</span></div>
|
|
1158
|
+
<div class="lesson-list" style="padding-top: 10px;">
|
|
1159
|
+
${lessons.map((row) => {
|
|
1160
|
+
const cells = row.cells || {};
|
|
1161
|
+
const lessonId = cells.ID || cells.Lesson || cells["Lesson ID"] || cells["ID"] || "";
|
|
1162
|
+
const summary = cells.Summary || cells["\u6458\u8981"] || cells.Pattern || cells.Status || "";
|
|
1163
|
+
return `<div class="lesson" data-open-lesson-drawer="${escapeAttr(lessonId)}">
|
|
1164
|
+
<strong>${escapeHtml(lessonId)}</strong>
|
|
1165
|
+
<p>${escapeHtml(summary)}</p>
|
|
1166
|
+
</div>`;
|
|
1167
|
+
}).join("") || emptyState(t("noLessons"))}
|
|
1168
|
+
</div>
|
|
1169
|
+
</section>`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function healthPanel() {
|
|
1173
|
+
const details = bundle.status?.checkState?.details || { failures: [], warnings: [] };
|
|
1174
|
+
return `<section class="health-panel">
|
|
1175
|
+
<div><h2>${t("releaseHealth")}</h2><p>${escapeHtml(bundle.status?.mode || "unknown")} · schema ${escapeHtml(bundle.status?.schemaVersion || "n/a")}</p></div>
|
|
1176
|
+
<div class="health-lists">
|
|
1177
|
+
<details ${details.failures?.length ? "open" : ""}><summary>${t("failures")} (${details.failures?.length || 0})</summary>${list(details.failures)}</details>
|
|
1178
|
+
<details><summary>${t("warnings")} (${details.warnings?.length || 0})</summary>${list(details.warnings?.slice(0, 40))}</details>
|
|
1179
|
+
</div>
|
|
1180
|
+
</section>`;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function taskDocument(task, fileName) {
|
|
1184
|
+
if (fileName === "__walkthrough__" && task.walkthroughPath) return findDocument(task.walkthroughPath);
|
|
1185
|
+
return findDocument(`${task.path}/${fileName}`);
|
|
327
1186
|
}
|
|
328
1187
|
|
|
329
1188
|
function findDocument(pathSuffix) {
|
|
330
|
-
return (bundle.documents?.documents || []).find((doc) => doc.path.endsWith(pathSuffix));
|
|
1189
|
+
return (bundle.documents?.documents || []).find((doc) => doc.path.endsWith(pathSuffix) || doc.path === pathSuffix);
|
|
331
1190
|
}
|
|
332
1191
|
|
|
333
|
-
function
|
|
334
|
-
|
|
1192
|
+
function mermaidLabel(id) {
|
|
1193
|
+
const node = (bundle.graph?.nodes || []).find((item) => item.id === id);
|
|
1194
|
+
return String(node?.label || id).replaceAll('"', "'").slice(0, 48);
|
|
335
1195
|
}
|
|
336
1196
|
|
|
337
|
-
function
|
|
1197
|
+
function mermaidId(value) {
|
|
1198
|
+
return `N_${String(value).replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function progressBar(value) {
|
|
1202
|
+
const score = Math.max(0, Math.min(100, Number(value) || 0));
|
|
1203
|
+
return `<div class="progress" aria-label="${score}%"><i style="width:${score}%"></i></div>`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function tag(value) {
|
|
338
1207
|
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(
|
|
1208
|
+
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" : "";
|
|
1209
|
+
return `<span class="tag ${klass}">${escapeHtml(label(raw))}</span>`;
|
|
341
1210
|
}
|
|
342
1211
|
|
|
343
1212
|
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;
|
|
1213
|
+
return t(`state_${value}`) || String(value || "unknown").replaceAll("_", " ");
|
|
365
1214
|
}
|
|
366
1215
|
|
|
367
|
-
function
|
|
368
|
-
|
|
369
|
-
|
|
1216
|
+
function list(items = []) {
|
|
1217
|
+
return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("") || `<li>${t("none")}</li>`}</ul>`;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function emptyState(text) {
|
|
1221
|
+
return `<div class="empty">${escapeHtml(text)}</div>`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function projectName() {
|
|
1225
|
+
return bundle.status?.project?.name || "Harness";
|
|
370
1226
|
}
|
|
371
1227
|
|
|
372
|
-
function
|
|
373
|
-
|
|
374
|
-
if (phases.length === 0) return 0;
|
|
375
|
-
const score = phases.reduce((sum, phase) => {
|
|
376
|
-
if (["present", "waived"].includes(phase.evidenceStatus)) return sum + 100;
|
|
377
|
-
if (phase.evidenceStatus === "partial") return sum + 50;
|
|
378
|
-
return sum;
|
|
379
|
-
}, 0);
|
|
380
|
-
return Math.round(score / phases.length);
|
|
1228
|
+
function themeLabel() {
|
|
1229
|
+
return state.theme === "dark" ? t("light") : state.theme === "light" ? t("system") : t("dark");
|
|
381
1230
|
}
|
|
382
1231
|
|
|
383
1232
|
function groupBy(items, fn) {
|
|
@@ -389,35 +1238,375 @@ function groupBy(items, fn) {
|
|
|
389
1238
|
}, {});
|
|
390
1239
|
}
|
|
391
1240
|
|
|
1241
|
+
function canUseWorkbenchAction(action) {
|
|
1242
|
+
return state.runtime?.mode === "workbench" && (state.runtime?.writableActions || []).includes(action);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
window.setModulePage = function(moduleKey, page) {
|
|
1246
|
+
state.modulePages = state.modulePages || {};
|
|
1247
|
+
state.modulePages[moduleKey] = page;
|
|
1248
|
+
app();
|
|
1249
|
+
};
|
|
1250
|
+
|
|
392
1251
|
function bind() {
|
|
393
|
-
document.querySelectorAll("[data-
|
|
394
|
-
state.
|
|
395
|
-
state.
|
|
1252
|
+
document.querySelectorAll("[data-search]").forEach((input) => input.addEventListener("input", () => {
|
|
1253
|
+
state.query = input.value;
|
|
1254
|
+
state.taskPageByGroup = {};
|
|
1255
|
+
state.taskGroupPage = 1;
|
|
396
1256
|
app();
|
|
397
1257
|
}));
|
|
398
|
-
document.querySelectorAll("[data-
|
|
399
|
-
state.
|
|
400
|
-
|
|
1258
|
+
document.querySelectorAll("[data-state-filter]").forEach((select) => select.addEventListener("change", () => {
|
|
1259
|
+
state.taskState = select.value;
|
|
1260
|
+
state.taskPageByGroup = {};
|
|
1261
|
+
state.taskGroupPage = 1;
|
|
1262
|
+
app();
|
|
1263
|
+
}));
|
|
1264
|
+
document.querySelectorAll("[data-group-mode]").forEach((select) => select.addEventListener("change", () => {
|
|
1265
|
+
state.taskGroupMode = select.value;
|
|
1266
|
+
state.taskPageByGroup = {};
|
|
1267
|
+
state.taskGroupPage = 1;
|
|
1268
|
+
app();
|
|
1269
|
+
}));
|
|
1270
|
+
document.querySelectorAll("[data-layout]").forEach((btn) => btn.addEventListener("click", () => {
|
|
1271
|
+
state.taskLayout = btn.dataset.layout;
|
|
1272
|
+
localStorage.setItem("harness.taskLayout", state.taskLayout);
|
|
1273
|
+
app();
|
|
1274
|
+
}));
|
|
1275
|
+
document.querySelectorAll("[data-render-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1276
|
+
state.renderMode = state.renderMode === "rendered" ? "source" : "rendered";
|
|
1277
|
+
app();
|
|
1278
|
+
}));
|
|
1279
|
+
document.querySelectorAll("[data-warning-filter]").forEach((button) => button.addEventListener("click", () => {
|
|
1280
|
+
state.warningFilter = button.dataset.warningFilter || "all";
|
|
1281
|
+
state.warningPage = 1;
|
|
401
1282
|
app();
|
|
402
1283
|
}));
|
|
403
|
-
document.querySelectorAll("[data-
|
|
404
|
-
state.
|
|
405
|
-
|
|
1284
|
+
document.querySelectorAll("[data-warning-filter-select]").forEach((select) => select.addEventListener("change", () => {
|
|
1285
|
+
state.warningFilter = select.value;
|
|
1286
|
+
state.warningPage = 1;
|
|
406
1287
|
app();
|
|
407
1288
|
}));
|
|
408
|
-
document.querySelectorAll("[data-
|
|
409
|
-
|
|
410
|
-
state.
|
|
1289
|
+
document.querySelectorAll("[data-page-kind]").forEach((button) => button.addEventListener("click", () => {
|
|
1290
|
+
const page = Math.max(1, Number(button.dataset.page) || 1);
|
|
1291
|
+
if (button.dataset.pageKind === "warning") state.warningPage = page;
|
|
1292
|
+
if (button.dataset.pageKind === "task-groups") state.taskGroupPage = page;
|
|
1293
|
+
if (button.dataset.pageKind === "task") state.taskPageByGroup[button.dataset.pageGroup || ""] = page;
|
|
411
1294
|
app();
|
|
412
1295
|
}));
|
|
413
|
-
document.querySelectorAll("[data-
|
|
414
|
-
|
|
1296
|
+
document.querySelectorAll("[data-runway-phase]").forEach((link) => link.addEventListener("click", () => {
|
|
1297
|
+
const phase = link.dataset.runwayPhase || "all";
|
|
1298
|
+
if (phase === "module-classification") state.taskGroupMode = "module";
|
|
1299
|
+
if (["triage", "active-task-contracts", "strict-cutover"].includes(phase)) state.warningFilter = phase === "triage" ? "all" : phase;
|
|
1300
|
+
state.warningPage = 1;
|
|
1301
|
+
state.taskGroupPage = 1;
|
|
1302
|
+
if (link.getAttribute("href") === "#/") app();
|
|
1303
|
+
}));
|
|
1304
|
+
document.querySelectorAll("[data-theme-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1305
|
+
state.theme = state.theme === "dark" ? "light" : state.theme === "light" ? "system" : "dark";
|
|
1306
|
+
localStorage.setItem("harness.theme", state.theme);
|
|
415
1307
|
app();
|
|
416
1308
|
}));
|
|
417
|
-
document.querySelectorAll("[data-
|
|
418
|
-
|
|
1309
|
+
document.querySelectorAll("[data-language-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1310
|
+
setLocale(locale === "zh" ? "en" : "zh");
|
|
1311
|
+
app();
|
|
1312
|
+
}));
|
|
1313
|
+
document.querySelectorAll("[data-open-drawer]").forEach((el) => el.addEventListener("click", (e) => {
|
|
1314
|
+
e.preventDefault();
|
|
1315
|
+
const taskId = el.dataset.openDrawer;
|
|
1316
|
+
openDrawer(taskId);
|
|
1317
|
+
}));
|
|
1318
|
+
document.querySelectorAll("[data-open-lesson-drawer]").forEach((el) => el.addEventListener("click", (e) => {
|
|
1319
|
+
e.preventDefault();
|
|
1320
|
+
const lessonId = el.dataset.openLessonDrawer;
|
|
1321
|
+
openLessonDrawer(lessonId);
|
|
1322
|
+
}));
|
|
1323
|
+
document.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
1324
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1325
|
+
if (overlay) overlay.addEventListener("click", closeDrawer);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function loadRuntime() {
|
|
1329
|
+
if (state.runtimeLoaded || window.__HARNESS_WORKBENCH__ !== true || !/^https?:$/.test(window.location.protocol)) return;
|
|
1330
|
+
state.runtimeLoaded = true;
|
|
1331
|
+
try {
|
|
1332
|
+
const response = await fetch("/api/runtime", { cache: "no-store" });
|
|
1333
|
+
if (!response.ok) return;
|
|
1334
|
+
state.runtime = await response.json();
|
|
1335
|
+
startRuntimePolling();
|
|
419
1336
|
app();
|
|
1337
|
+
} catch {
|
|
1338
|
+
state.runtime = { mode: "static", csrfToken: "", writableActions: [] };
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function startRuntimePolling() {
|
|
1343
|
+
if (!state.runtime?.autoRefresh || state.runtimePoller) return;
|
|
1344
|
+
state.runtimePoller = setInterval(async () => {
|
|
1345
|
+
try {
|
|
1346
|
+
const response = await fetch("/api/runtime", { cache: "no-store" });
|
|
1347
|
+
if (!response.ok) return;
|
|
1348
|
+
const nextRuntime = await response.json();
|
|
1349
|
+
if (state.runtime?.snapshotVersion && nextRuntime.snapshotVersion !== state.runtime.snapshotVersion) {
|
|
1350
|
+
window.location.reload();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
state.runtime = nextRuntime;
|
|
1354
|
+
} catch {
|
|
1355
|
+
clearInterval(state.runtimePoller);
|
|
1356
|
+
state.runtimePoller = null;
|
|
1357
|
+
}
|
|
1358
|
+
}, 1500);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function completeReviewFromDashboard(taskId) {
|
|
1362
|
+
const result = document.querySelector(`[data-review-result="${CSS.escape(taskId)}"]`);
|
|
1363
|
+
const checkbox = document.querySelector(`[data-review-confirm-check="${CSS.escape(taskId)}"]`);
|
|
1364
|
+
const confirmInput = document.querySelector(`[data-review-confirm-text="${CSS.escape(taskId)}"]`);
|
|
1365
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
1366
|
+
if (!checkbox?.checked) {
|
|
1367
|
+
if (result) result.textContent = t("reviewChecklistRequired");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (!confirmInput?.value || ![task?.shortId, task?.id].includes(confirmInput.value.trim())) {
|
|
1371
|
+
if (result) result.textContent = t("reviewConfirmTextMismatch");
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (result) result.textContent = t("reviewSubmitting");
|
|
1375
|
+
try {
|
|
1376
|
+
const response = await fetch("/api/tasks/review-complete", {
|
|
1377
|
+
method: "POST",
|
|
1378
|
+
headers: {
|
|
1379
|
+
"content-type": "application/json",
|
|
1380
|
+
"x-harness-csrf": state.runtime?.csrfToken || "",
|
|
1381
|
+
},
|
|
1382
|
+
body: JSON.stringify({
|
|
1383
|
+
taskId,
|
|
1384
|
+
confirmText: confirmInput.value.trim(),
|
|
1385
|
+
reviewer: "Human Reviewer",
|
|
1386
|
+
message: "confirmed from dashboard workbench",
|
|
1387
|
+
}),
|
|
1388
|
+
});
|
|
1389
|
+
const payload = await response.json();
|
|
1390
|
+
if (!response.ok) throw new Error(payload.error || t("reviewCompleteFailed"));
|
|
1391
|
+
if (result) result.textContent = t("reviewCompleteSuccess");
|
|
1392
|
+
setTimeout(() => window.location.reload(), 500);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
if (result) result.textContent = `${t("reviewCompleteFailed")}: ${error.message}`;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function renderDrawerContent(taskId) {
|
|
1399
|
+
const task = (bundle.status?.tasks || []).find((item) => item.id === taskId);
|
|
1400
|
+
if (!task) return `<div class="empty">${t("taskNotFound")}</div>`;
|
|
1401
|
+
|
|
1402
|
+
const header = `
|
|
1403
|
+
<div class="task-drawer-header">
|
|
1404
|
+
<div>
|
|
1405
|
+
<h2>${escapeHtml(task.title)}</h2>
|
|
1406
|
+
<p style="font-family: var(--font-mono); font-size: 11px; margin: 4px 0 0; color: var(--muted);">${escapeHtml(task.id)}</p>
|
|
1407
|
+
</div>
|
|
1408
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
1409
|
+
</div>
|
|
1410
|
+
`;
|
|
1411
|
+
|
|
1412
|
+
const timeline = phaseTimeline(task);
|
|
1413
|
+
const documents = taskDocumentLibrary(task, "");
|
|
1414
|
+
const findings = openFindings(task);
|
|
1415
|
+
const evidence = evidenceList(task);
|
|
1416
|
+
|
|
1417
|
+
const body = `
|
|
1418
|
+
<div class="task-drawer-body stack">
|
|
1419
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: var(--paper-2); padding: 12px 16px; border-radius: 8px;">
|
|
1420
|
+
<div style="font-size: 24px; font-weight: 800; color: var(--accent);">${task.completion}%</div>
|
|
1421
|
+
<a href="#/tasks/${encodeURIComponent(task.id)}" class="btn-drawer-trigger" style="text-decoration: none;">${t("fullView")}</a>
|
|
1422
|
+
</div>
|
|
1423
|
+
${taskStateSummary(task)}
|
|
1424
|
+
${reviewActionPanel(task)}
|
|
1425
|
+
${timeline}
|
|
1426
|
+
${documents}
|
|
1427
|
+
${findings}
|
|
1428
|
+
${evidence}
|
|
1429
|
+
</div>
|
|
1430
|
+
`;
|
|
1431
|
+
|
|
1432
|
+
return header + body;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function openDrawer(taskId) {
|
|
1436
|
+
const drawer = document.getElementById("task-drawer");
|
|
1437
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1438
|
+
if (!drawer || !overlay) return;
|
|
1439
|
+
drawer.innerHTML = renderDrawerContent(taskId);
|
|
1440
|
+
drawer.classList.add("active");
|
|
1441
|
+
overlay.classList.add("active");
|
|
1442
|
+
|
|
1443
|
+
drawer.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
|
|
1444
|
+
drawer.querySelectorAll("[data-render-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
1445
|
+
state.renderMode = state.renderMode === "rendered" ? "source" : "rendered";
|
|
1446
|
+
openDrawer(taskId);
|
|
420
1447
|
}));
|
|
1448
|
+
drawer.querySelectorAll("[data-review-complete]").forEach((button) => button.addEventListener("click", () => completeReviewFromDashboard(button.dataset.reviewComplete)));
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function renderLessonDrawerContent(lessonId) {
|
|
1452
|
+
const lessonTable = (bundle.tables?.tables || []).find((table) => table.kind === "lessons-ssot");
|
|
1453
|
+
const row = (lessonTable?.rows || []).find((r) => {
|
|
1454
|
+
const cells = r.cells || {};
|
|
1455
|
+
const id = cells.ID || cells.Lesson || cells["Lesson ID"] || cells["ID"] || "";
|
|
1456
|
+
return id === lessonId;
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
if (!row) {
|
|
1460
|
+
return `<div class="task-drawer-header">
|
|
1461
|
+
<h2>${escapeHtml(lessonId)}</h2>
|
|
1462
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
1463
|
+
</div>
|
|
1464
|
+
<div class="task-drawer-body">
|
|
1465
|
+
<div class="empty">${t("lessonNotFound")}</div>
|
|
1466
|
+
</div>`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const cells = row.cells || {};
|
|
1470
|
+
const summary = cells.Summary || cells["\u6458\u8981"] || cells.Pattern || cells.Status || "";
|
|
1471
|
+
const docPath = cells["\u8be6\u60c5\u6587\u6863"] || cells.Document || cells.document || "";
|
|
1472
|
+
|
|
1473
|
+
let doc = null;
|
|
1474
|
+
if (docPath) {
|
|
1475
|
+
doc = findDocument(docPath);
|
|
1476
|
+
}
|
|
1477
|
+
if (!doc) {
|
|
1478
|
+
doc = (bundle.documents?.documents || []).find((d) => d.path.includes(lessonId) || d.path.endsWith(`${lessonId}.md`));
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const header = `
|
|
1482
|
+
<div class="task-drawer-header">
|
|
1483
|
+
<div>
|
|
1484
|
+
<h2>${escapeHtml(lessonId)}</h2>
|
|
1485
|
+
<p style="font-size: 12px; margin: 4px 0 0; color: var(--muted); font-weight: 600;">${escapeHtml(summary)}</p>
|
|
1486
|
+
</div>
|
|
1487
|
+
<button class="btn-close" data-close-drawer>×</button>
|
|
1488
|
+
</div>
|
|
1489
|
+
`;
|
|
1490
|
+
|
|
1491
|
+
let markdownBody = "";
|
|
1492
|
+
if (doc && doc.content) {
|
|
1493
|
+
markdownBody = `<div class="markdown">${window.HarnessMarkdown.render(doc.content, "rendered")}</div>`;
|
|
1494
|
+
} else {
|
|
1495
|
+
const rowsHtml = Object.entries(cells)
|
|
1496
|
+
.map(([key, val]) => `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(val)}</td></tr>`)
|
|
1497
|
+
.join("");
|
|
1498
|
+
markdownBody = `
|
|
1499
|
+
<div style="margin-bottom: 20px; background: var(--paper-2); padding: 16px; border-radius: 8px; border: 1px dashed var(--line);">
|
|
1500
|
+
<p style="margin: 0; font-size: 13px; color: var(--muted);">${t("lessonDocMissing")}</p>
|
|
1501
|
+
</div>
|
|
1502
|
+
<table class="rendered-table" style="width: 100%;">
|
|
1503
|
+
<tbody>${rowsHtml}</tbody>
|
|
1504
|
+
</table>
|
|
1505
|
+
`;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const body = `
|
|
1509
|
+
<div class="task-drawer-body stack">
|
|
1510
|
+
${markdownBody}
|
|
1511
|
+
</div>
|
|
1512
|
+
`;
|
|
1513
|
+
|
|
1514
|
+
return header + body;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function openLessonDrawer(lessonId) {
|
|
1518
|
+
const drawer = document.getElementById("task-drawer");
|
|
1519
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1520
|
+
if (!drawer || !overlay) return;
|
|
1521
|
+
drawer.innerHTML = renderLessonDrawerContent(lessonId);
|
|
1522
|
+
drawer.classList.add("active");
|
|
1523
|
+
overlay.classList.add("active");
|
|
1524
|
+
|
|
1525
|
+
drawer.querySelector("[data-close-drawer]").addEventListener("click", closeDrawer);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function closeDrawer() {
|
|
1529
|
+
const drawer = document.getElementById("task-drawer");
|
|
1530
|
+
const overlay = document.getElementById("drawer-overlay");
|
|
1531
|
+
if (drawer) drawer.classList.remove("active");
|
|
1532
|
+
if (overlay) overlay.classList.remove("active");
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function ledgerPanel() {
|
|
1536
|
+
const ledgerTable = (bundle.tables?.tables || []).find((table) => table.kind === "harness-ledger");
|
|
1537
|
+
const rows = ledgerTable?.rows || [];
|
|
1538
|
+
|
|
1539
|
+
let closedCount = 0;
|
|
1540
|
+
let openCount = 0;
|
|
1541
|
+
let blockedCount = 0;
|
|
1542
|
+
|
|
1543
|
+
let lessonsReviewed = 0;
|
|
1544
|
+
let lessonsTotal = 0;
|
|
1545
|
+
|
|
1546
|
+
let evidenceAudited = 0;
|
|
1547
|
+
let evidenceTotal = 0;
|
|
1548
|
+
|
|
1549
|
+
for (const row of rows) {
|
|
1550
|
+
const cells = row.cells || {};
|
|
1551
|
+
const status = String(cells.Status || cells["\u72b6\u6001"] || "").toLowerCase();
|
|
1552
|
+
if (status.includes("close") || status.includes("done") || status.includes("\u7ed3") || status.includes("\u5b8c")) {
|
|
1553
|
+
closedCount++;
|
|
1554
|
+
} else if (status.includes("block") || status.includes("\u963b")) {
|
|
1555
|
+
blockedCount++;
|
|
1556
|
+
} else {
|
|
1557
|
+
openCount++;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const lesson = String(cells.Lessons || cells["\u7ecf\u9a8c"] || cells["\u7ecf\u9a8c\u5ba1\u67e5"] || cells["Lesson"] || "");
|
|
1561
|
+
if (lesson) {
|
|
1562
|
+
lessonsTotal++;
|
|
1563
|
+
if (lesson.toLowerCase().includes("pass") || lesson.includes("\u901a\u8fc7") || lesson.includes("\u5c31\u7eea") || lesson.toLowerCase().includes("checked") || lesson.toLowerCase().includes("done")) {
|
|
1564
|
+
lessonsReviewed++;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const evidence = String(cells.Evidence || cells["\u8bc1\u636e"] || cells["\u9a8c\u8bc1\u8bc1\u636e"] || cells["Evidence Checked"] || "");
|
|
1569
|
+
if (evidence) {
|
|
1570
|
+
evidenceTotal++;
|
|
1571
|
+
if (evidence.toLowerCase().includes("pass") || evidence.includes("\u901a\u8fc7") || evidence.toLowerCase().includes("present") || evidence.toLowerCase().includes("verified") || evidence.toLowerCase().includes("done")) {
|
|
1572
|
+
evidenceAudited++;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const total = closedCount + openCount + blockedCount || 1;
|
|
1578
|
+
const closedPct = Math.round((closedCount / total) * 100);
|
|
1579
|
+
const openPct = Math.round((openCount / total) * 100);
|
|
1580
|
+
const blockedPct = total - closedPct - openPct;
|
|
1581
|
+
|
|
1582
|
+
const lessonsPct = lessonsTotal ? Math.round((lessonsReviewed / lessonsTotal) * 100) : 0;
|
|
1583
|
+
const evidencePct = evidenceTotal ? Math.round((evidenceAudited / evidenceTotal) * 100) : 0;
|
|
1584
|
+
|
|
1585
|
+
if (rows.length === 0) return "";
|
|
1586
|
+
|
|
1587
|
+
return `<section class="ledger-panel">
|
|
1588
|
+
<h2>${t("ssotLedger")}</h2>
|
|
1589
|
+
<div class="ledger-split-bar" title="${t("tagClosed")}: ${closedCount}, ${t("tagOpen")}: ${openCount}, ${t("tagBlocked")}: ${blockedCount}">
|
|
1590
|
+
<div class="ledger-split-segment closed" style="width: ${closedPct}%"></div>
|
|
1591
|
+
<div class="ledger-split-segment open" style="width: ${openPct}%"></div>
|
|
1592
|
+
<div class="ledger-split-segment blocked" style="width: ${Math.max(0, blockedPct)}%"></div>
|
|
1593
|
+
</div>
|
|
1594
|
+
<div class="ledger-split-legend">
|
|
1595
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot closed"></i>${t("tagClosed")} (${closedCount})</span>
|
|
1596
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot open"></i>${t("tagOpen")} (${openCount})</span>
|
|
1597
|
+
<span class="ledger-split-legend-item"><i class="ledger-split-legend-dot blocked"></i>${t("tagBlocked")} (${blockedCount})</span>
|
|
1598
|
+
</div>
|
|
1599
|
+
<div class="ledger-gauge-row">
|
|
1600
|
+
<div class="ledger-gauge-card">
|
|
1601
|
+
<span>${t("lessonsCheckRate")}</span>
|
|
1602
|
+
<strong>${lessonsPct}%</strong>
|
|
1603
|
+
</div>
|
|
1604
|
+
<div class="ledger-gauge-card">
|
|
1605
|
+
<span>${t("evidenceAuditRate")}</span>
|
|
1606
|
+
<strong>${evidencePct}%</strong>
|
|
1607
|
+
</div>
|
|
1608
|
+
</div>
|
|
1609
|
+
</section>`;
|
|
421
1610
|
}
|
|
422
1611
|
|
|
423
1612
|
function escapeHtml(value) {
|
|
@@ -432,4 +1621,6 @@ function escapeAttr(value) {
|
|
|
432
1621
|
return escapeHtml(value).replaceAll("'", "'");
|
|
433
1622
|
}
|
|
434
1623
|
|
|
1624
|
+
window.addEventListener("hashchange", app);
|
|
435
1625
|
app();
|
|
1626
|
+
loadRuntime();
|