@deksden-com/dd-flow-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +274 -0
- package/dist/cli/help.js +308 -0
- package/dist/cli/run-cli.js +945 -0
- package/dist/cli.js +4 -0
- package/dist/domain/contracts.js +57 -0
- package/dist/domain/entity-ids.js +47 -0
- package/dist/domain/flow-contract.js +233 -0
- package/dist/domain/validation.js +91 -0
- package/dist/protocol/local-files.js +141 -0
- package/dist/runtime/context.js +11 -0
- package/dist/schemas/code-stage-report.schema.json +181 -0
- package/dist/schemas/flow-run-index.schema.json +129 -0
- package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
- package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
- package/dist/schemas/merge-stage-report.schema.json +135 -0
- package/dist/services/audit.js +19 -0
- package/dist/services/cleanup.js +310 -0
- package/dist/services/config.js +143 -0
- package/dist/services/dashboard.js +436 -0
- package/dist/services/hooks.js +929 -0
- package/dist/services/lanes.js +327 -0
- package/dist/services/memory-permissions.js +344 -0
- package/dist/services/merge-queue.js +333 -0
- package/dist/services/plans.js +149 -0
- package/dist/services/projects.js +286 -0
- package/dist/services/protocols.js +606 -0
- package/dist/services/runs.js +359 -0
- package/dist/services/schema-validation.js +185 -0
- package/dist/services/sessions.js +365 -0
- package/dist/services/worktrees.js +204 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/json.js +17 -0
- package/dist/storage/database.js +325 -0
- package/dist/storage/paths.js +56 -0
- package/package.json +44 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { cmuxModes } from "../domain/contracts.js";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
export const defaultProjectConfig = {
|
|
5
|
+
dashboard: {
|
|
6
|
+
auto_refresh: true,
|
|
7
|
+
project: true,
|
|
8
|
+
global: true,
|
|
9
|
+
open_on_session_start: false,
|
|
10
|
+
open_on_merge_worker_start: false,
|
|
11
|
+
markdown_path: ".tasks/dd-flow-dashboard.md",
|
|
12
|
+
global_markdown_path: "dashboard.md"
|
|
13
|
+
},
|
|
14
|
+
integrations: {
|
|
15
|
+
cmux: {
|
|
16
|
+
mode: "off"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export function getProjectConfigStatus(context, project) {
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
project_id: project.id,
|
|
24
|
+
project_root: project.root,
|
|
25
|
+
config: readProjectConfig(context, project.id),
|
|
26
|
+
overrides: configOverrides(context, project.id)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function setProjectConfigValue(context, project, input) {
|
|
30
|
+
const value = parseConfigValue(input.key, input.value);
|
|
31
|
+
const now = context.now();
|
|
32
|
+
context.db.run(`INSERT INTO project_config (project_id, key, value_json, created_at, updated_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(project_id, key) DO UPDATE SET
|
|
35
|
+
value_json = excluded.value_json,
|
|
36
|
+
updated_at = excluded.updated_at`, [project.id, input.key, JSON.stringify(value), now, now]);
|
|
37
|
+
return getProjectConfigStatus(context, project);
|
|
38
|
+
}
|
|
39
|
+
export function readProjectConfig(context, projectId) {
|
|
40
|
+
migrateLegacyConfig(context, projectId);
|
|
41
|
+
const config = structuredClone(defaultProjectConfig);
|
|
42
|
+
for (const row of configOverrides(context, projectId)) {
|
|
43
|
+
applyConfigValue(config, row.key, JSON.parse(row.value_json));
|
|
44
|
+
}
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
export function dashboardMarkdownPath(projectRoot, config) {
|
|
48
|
+
const configured = config.dashboard.markdown_path;
|
|
49
|
+
return path.isAbsolute(configured) ? configured : path.join(projectRoot, configured);
|
|
50
|
+
}
|
|
51
|
+
export function globalDashboardMarkdownPath(context, config) {
|
|
52
|
+
const configured = config?.dashboard.global_markdown_path ?? defaultProjectConfig.dashboard.global_markdown_path;
|
|
53
|
+
return path.isAbsolute(configured) ? configured : path.join(context.ddFlowHome, configured);
|
|
54
|
+
}
|
|
55
|
+
function configOverrides(context, projectId) {
|
|
56
|
+
return context.db.all("SELECT key, value_json, updated_at FROM project_config WHERE project_id = ? ORDER BY key ASC", [projectId]);
|
|
57
|
+
}
|
|
58
|
+
function parseConfigValue(key, value) {
|
|
59
|
+
if (key === "integrations.cmux.mode") {
|
|
60
|
+
if (!cmuxModes.includes(value)) {
|
|
61
|
+
throw new AppError("validation", "--value for integrations.cmux.mode must be off, auto, or required", 2);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (key === "dashboard.auto_refresh" ||
|
|
66
|
+
key === "dashboard.project" ||
|
|
67
|
+
key === "dashboard.global" ||
|
|
68
|
+
key === "dashboard.open_on_session_start" ||
|
|
69
|
+
key === "dashboard.open_on_merge_worker_start") {
|
|
70
|
+
if (value === "true")
|
|
71
|
+
return true;
|
|
72
|
+
if (value === "false")
|
|
73
|
+
return false;
|
|
74
|
+
throw new AppError("validation", `--value for ${key} must be true or false`, 2);
|
|
75
|
+
}
|
|
76
|
+
if (key === "dashboard.markdown_path" || key === "dashboard.global_markdown_path") {
|
|
77
|
+
if (!value.trim()) {
|
|
78
|
+
throw new AppError("validation", `--value for ${key} must not be empty`, 2);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
throw new AppError("validation", `Unknown project config key: ${key}`, 2);
|
|
83
|
+
}
|
|
84
|
+
function applyConfigValue(config, key, value) {
|
|
85
|
+
if (key === "integrations.cmux.mode" && cmuxModes.includes(value)) {
|
|
86
|
+
config.integrations.cmux.mode = value;
|
|
87
|
+
}
|
|
88
|
+
else if (key === "dashboard.auto_refresh" && typeof value === "boolean") {
|
|
89
|
+
config.dashboard.auto_refresh = value;
|
|
90
|
+
}
|
|
91
|
+
else if (key === "dashboard.project" && typeof value === "boolean") {
|
|
92
|
+
config.dashboard.project = value;
|
|
93
|
+
}
|
|
94
|
+
else if (key === "dashboard.global" && typeof value === "boolean") {
|
|
95
|
+
config.dashboard.global = value;
|
|
96
|
+
}
|
|
97
|
+
else if (key === "dashboard.open_on_session_start" && typeof value === "boolean") {
|
|
98
|
+
config.dashboard.open_on_session_start = value;
|
|
99
|
+
}
|
|
100
|
+
else if (key === "dashboard.open_on_merge_worker_start" && typeof value === "boolean") {
|
|
101
|
+
config.dashboard.open_on_merge_worker_start = value;
|
|
102
|
+
}
|
|
103
|
+
else if (key === "dashboard.markdown_path" && typeof value === "string") {
|
|
104
|
+
config.dashboard.markdown_path = value;
|
|
105
|
+
}
|
|
106
|
+
else if (key === "dashboard.global_markdown_path" && typeof value === "string") {
|
|
107
|
+
config.dashboard.global_markdown_path = value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function migrateLegacyConfig(context, projectId) {
|
|
111
|
+
const legacyRows = context.db.all(`SELECT key, value_json, updated_at FROM project_config
|
|
112
|
+
WHERE project_id = ? AND key IN (
|
|
113
|
+
'integrations.cmux.dashboard',
|
|
114
|
+
'integrations.cmux.open_on_session_start',
|
|
115
|
+
'integrations.cmux.open_on_merge_worker_start',
|
|
116
|
+
'integrations.cmux.markdown_path'
|
|
117
|
+
)
|
|
118
|
+
ORDER BY key ASC`, [projectId]);
|
|
119
|
+
if (legacyRows.length === 0) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const existingKeys = new Set(configOverrides(context, projectId).map((row) => row.key));
|
|
123
|
+
const now = context.now();
|
|
124
|
+
for (const row of legacyRows) {
|
|
125
|
+
const nextKey = legacyConfigKey(row.key);
|
|
126
|
+
if (!existingKeys.has(nextKey)) {
|
|
127
|
+
context.db.run(`INSERT INTO project_config (project_id, key, value_json, created_at, updated_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?)`, [projectId, nextKey, row.value_json, now, now]);
|
|
129
|
+
}
|
|
130
|
+
context.db.run("DELETE FROM project_config WHERE project_id = ? AND key = ?", [projectId, row.key]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function legacyConfigKey(key) {
|
|
134
|
+
if (key === "integrations.cmux.dashboard")
|
|
135
|
+
return "dashboard.project";
|
|
136
|
+
if (key === "integrations.cmux.open_on_session_start")
|
|
137
|
+
return "dashboard.open_on_session_start";
|
|
138
|
+
if (key === "integrations.cmux.open_on_merge_worker_start")
|
|
139
|
+
return "dashboard.open_on_merge_worker_start";
|
|
140
|
+
if (key === "integrations.cmux.markdown_path")
|
|
141
|
+
return "dashboard.markdown_path";
|
|
142
|
+
return key;
|
|
143
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } from "../domain/contracts.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
+
import { dashboardMarkdownPath, globalDashboardMarkdownPath, readProjectConfig } from "./config.js";
|
|
8
|
+
import { queueForProject } from "./merge-queue.js";
|
|
9
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
+
import { activeFlowSessionsForProject } from "./sessions.js";
|
|
11
|
+
export function getCmuxStatus(context, input) {
|
|
12
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
|
+
return { ok: true, project_root: project.root, cmux: detectCmux(context) };
|
|
14
|
+
}
|
|
15
|
+
export function renderDashboard(context, input) {
|
|
16
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
17
|
+
const config = readProjectConfig(context, project.id);
|
|
18
|
+
const output = input.output
|
|
19
|
+
? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
|
|
20
|
+
: dashboardMarkdownPath(project.root, config);
|
|
21
|
+
const markdown = renderProjectDashboardMarkdown(context, project, output);
|
|
22
|
+
writeMarkdown(output, markdown);
|
|
23
|
+
return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
24
|
+
}
|
|
25
|
+
export function renderGlobalDashboard(context, input = {}) {
|
|
26
|
+
const output = input.output
|
|
27
|
+
? path.resolve(input.output)
|
|
28
|
+
: globalDashboardMarkdownPath(context);
|
|
29
|
+
const markdown = renderGlobalDashboardMarkdown(context, output);
|
|
30
|
+
writeMarkdown(output, markdown);
|
|
31
|
+
return { ok: true, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
32
|
+
}
|
|
33
|
+
export function openDashboard(context, input) {
|
|
34
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
35
|
+
const config = readProjectConfig(context, project.id);
|
|
36
|
+
if (input.viewer && input.viewer !== "cmux") {
|
|
37
|
+
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
38
|
+
}
|
|
39
|
+
const dashboardPath = dashboardMarkdownPath(project.root, config);
|
|
40
|
+
return openCmuxDashboard(context, project.root, dashboardPath);
|
|
41
|
+
}
|
|
42
|
+
export function refreshDashboard(context, input) {
|
|
43
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
44
|
+
const config = readProjectConfig(context, project.id);
|
|
45
|
+
const openMode = input.open ?? "auto";
|
|
46
|
+
if (!["auto", "true", "false"].includes(openMode)) {
|
|
47
|
+
throw new AppError("validation", "--open must be auto, true, or false", 2);
|
|
48
|
+
}
|
|
49
|
+
if (!config.dashboard.project) {
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
project_root: project.root,
|
|
53
|
+
dashboard: { skipped: true, reason: "dashboard_project_disabled" },
|
|
54
|
+
open: { ok: true, skipped: true, reason: "dashboard_project_disabled" }
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const rendered = renderDashboard(context, { projectRoot: project.root });
|
|
58
|
+
const global = config.dashboard.global
|
|
59
|
+
? renderGlobalDashboard(context, { output: globalDashboardMarkdownPath(context, config) })
|
|
60
|
+
: { dashboard: { skipped: true, reason: "dashboard_global_disabled" } };
|
|
61
|
+
const shouldOpen = openMode === "true" || (openMode === "auto" && config.integrations.cmux.mode !== "off");
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
project_root: project.root,
|
|
65
|
+
dashboard: rendered.dashboard,
|
|
66
|
+
global_dashboard: global.dashboard,
|
|
67
|
+
open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path) : { ok: true, skipped: true, reason: "open_disabled" }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function refreshGlobalDashboard(context, input = {}) {
|
|
71
|
+
return renderGlobalDashboard(context, input);
|
|
72
|
+
}
|
|
73
|
+
export function autoRefreshDashboards(context, input) {
|
|
74
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
75
|
+
const config = readProjectConfig(context, project.id);
|
|
76
|
+
if (!config.dashboard.auto_refresh) {
|
|
77
|
+
return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
|
|
78
|
+
}
|
|
79
|
+
const result = { ok: true, project_root: project.root };
|
|
80
|
+
if (config.dashboard.project) {
|
|
81
|
+
const output = dashboardMarkdownPath(project.root, config);
|
|
82
|
+
const markdown = renderProjectDashboardMarkdown(context, project, output);
|
|
83
|
+
writeMarkdown(output, markdown);
|
|
84
|
+
result.project = { ok: true, path: output, bytes: Buffer.byteLength(markdown) };
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
result.project = { ok: true, skipped: true, reason: "dashboard_project_disabled" };
|
|
88
|
+
}
|
|
89
|
+
if (config.dashboard.global) {
|
|
90
|
+
const output = globalDashboardMarkdownPath(context, config);
|
|
91
|
+
const markdown = renderGlobalDashboardMarkdown(context, output);
|
|
92
|
+
writeMarkdown(output, markdown);
|
|
93
|
+
result.global = { ok: true, path: output, bytes: Buffer.byteLength(markdown) };
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
result.global = { ok: true, skipped: true, reason: "dashboard_global_disabled" };
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
export function dashboardSummary(context, projectId, projectRoot) {
|
|
101
|
+
const config = readProjectConfig(context, projectId);
|
|
102
|
+
return {
|
|
103
|
+
project_markdown_path: dashboardMarkdownPath(projectRoot, config),
|
|
104
|
+
global_markdown_path: globalDashboardMarkdownPath(context, config),
|
|
105
|
+
auto_refresh: config.dashboard.auto_refresh,
|
|
106
|
+
project_enabled: config.dashboard.project,
|
|
107
|
+
global_enabled: config.dashboard.global,
|
|
108
|
+
cmux_mode: config.integrations.cmux.mode
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function openCmuxDashboard(context, projectRoot, dashboardPath) {
|
|
112
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
113
|
+
const config = readProjectConfig(context, project.id);
|
|
114
|
+
if (config.integrations.cmux.mode === "off") {
|
|
115
|
+
return { ok: true, skipped: true, reason: "cmux_off" };
|
|
116
|
+
}
|
|
117
|
+
const cmux = detectCmux(context);
|
|
118
|
+
if (!cmux.available) {
|
|
119
|
+
if (config.integrations.cmux.mode === "required") {
|
|
120
|
+
throw new AppError("cmux_unavailable", "cmux is required by project config but is unavailable", 1, { cmux });
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
|
|
123
|
+
}
|
|
124
|
+
if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
|
|
125
|
+
return { ok: true, opened: false, dry_run: true, command: ["cmux", "markdown", "open", dashboardPath] };
|
|
126
|
+
}
|
|
127
|
+
const result = spawnSync("cmux", ["markdown", "open", dashboardPath], { encoding: "utf8" });
|
|
128
|
+
if (result.status !== 0) {
|
|
129
|
+
if (config.integrations.cmux.mode === "required") {
|
|
130
|
+
throw new AppError("cmux_open_failed", "cmux markdown open failed", 1, {
|
|
131
|
+
status: result.status,
|
|
132
|
+
stderr: result.stderr.trim()
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, opened: true, command: ["cmux", "markdown", "open", dashboardPath] };
|
|
138
|
+
}
|
|
139
|
+
function detectCmux(context) {
|
|
140
|
+
if (context.env.DD_FLOW_CMUX_AVAILABLE === "true") {
|
|
141
|
+
return { available: true, source: "env" };
|
|
142
|
+
}
|
|
143
|
+
if (context.env.DD_FLOW_CMUX_AVAILABLE === "false") {
|
|
144
|
+
return { available: false, source: "env", reason: "forced_unavailable" };
|
|
145
|
+
}
|
|
146
|
+
const result = spawnSync("cmux", ["--version"], { encoding: "utf8" });
|
|
147
|
+
return result.status === 0
|
|
148
|
+
? { available: true, source: "path", version: result.stdout.trim() }
|
|
149
|
+
: { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
|
|
150
|
+
}
|
|
151
|
+
function renderProjectDashboardMarkdown(context, project, output) {
|
|
152
|
+
const protocols = protocolsForProject(context, project.id);
|
|
153
|
+
const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
154
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
155
|
+
const queue = queueForProject(context, project.id);
|
|
156
|
+
const lanes = context.db.all("SELECT name, workspace_path, status FROM lanes WHERE project_id = ? ORDER BY name ASC", [project.id]);
|
|
157
|
+
const locks = context.db.all(`SELECT lane, worker_id, status, expires_at FROM lane_locks
|
|
158
|
+
WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 10`, [project.id]);
|
|
159
|
+
const worktrees = context.db.all(`SELECT protocol_id, feature_branch, worktree_path, bootstrap_status, status, updated_at
|
|
160
|
+
FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC LIMIT 10`, [project.id]);
|
|
161
|
+
const hookEvents = context.db.all(`SELECT protocol_id, session_id, event_name, tool_name, status, sanitized_summary, created_at
|
|
162
|
+
FROM codex_hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 10`, [project.id]);
|
|
163
|
+
const activeDefs = protocols.flatMap((protocol) => jsonArray(protocol.active_def_json).map((entry) => ({ protocol: protocol.id, entry })));
|
|
164
|
+
const blockers = protocols.flatMap((protocol) => jsonArray(protocol.blockers_json).map((entry) => ({ protocol: protocol.id, entry })));
|
|
165
|
+
const activeLocks = locks.filter((lock) => lock.status === "active");
|
|
166
|
+
const recentQueue = [...queue].reverse().slice(0, 5);
|
|
167
|
+
const recentProtocols = protocols.slice(0, 6);
|
|
168
|
+
const config = readProjectConfig(context, project.id);
|
|
169
|
+
const globalOutput = globalDashboardMarkdownPath(context, config);
|
|
170
|
+
const latestProtocol = protocols.find((protocol) => isActiveProtocolStatus(protocol.status)) ?? protocols[0];
|
|
171
|
+
const latestProtocolSummary = latestProtocol ? planSummaryForProtocol(context, latestProtocol.id) : null;
|
|
172
|
+
const lines = [
|
|
173
|
+
"# 🧭 dd-flow Dashboard",
|
|
174
|
+
"",
|
|
175
|
+
`**Updated:** ${context.now()}`,
|
|
176
|
+
`**Project:** ${cell(project.root)}`,
|
|
177
|
+
`**Dashboard:** ${cell(output)}`,
|
|
178
|
+
`**Global:** ${cell(globalOutput)}`,
|
|
179
|
+
"",
|
|
180
|
+
"## ⚡ Snapshot",
|
|
181
|
+
"| work | queue | locks | blockers | latest |",
|
|
182
|
+
"| --- | --- | --- | --- | --- |",
|
|
183
|
+
`| ${cell(`${activeProtocols.length} protocols / ${sessions.length} sessions`)} | ${cell(queueStatusSummary(queue))} | ${cell(activeLocks.length)} | ${cell(blockers.length + activeDefs.length)} | ${cell(latestProtocol ? `${shortId(latestProtocol.id)} ${latestProtocol.stage}/${latestProtocol.status} ${latestProtocolSummary?.done ?? 0}/${latestProtocolSummary?.total ?? 0}` : "none")} |`,
|
|
184
|
+
"",
|
|
185
|
+
"## 🧩 Active Work",
|
|
186
|
+
activeProtocols.length || sessions.length ? "| item | state | next | workspace |" : "No active work."
|
|
187
|
+
];
|
|
188
|
+
if (activeProtocols.length || sessions.length) {
|
|
189
|
+
lines.push("| --- | --- | --- | --- |");
|
|
190
|
+
for (const protocol of activeProtocols.slice(0, 5)) {
|
|
191
|
+
const summary = planSummaryForProtocol(context, protocol.id);
|
|
192
|
+
lines.push(`| ${statusIcon(protocol.status)} ${cell(shortId(protocol.id))} | ${cell(`${protocol.stage}/${protocol.status} · ${summary.done}/${summary.total}`)} | ${cell(protocol.next_action ?? "none")} | ${cell(workspaceForProtocol(worktrees, protocol.id) ?? "-")} |`);
|
|
193
|
+
}
|
|
194
|
+
for (const session of sessions.slice(0, 6)) {
|
|
195
|
+
lines.push(`| ${statusIcon(session.status)} ${cell(session.flow_kind)} | ${cell(session.worker_id ?? shortId(session.session_id))} | ${cell(session.next_action ?? "none")} | ${cell(compactPath(session.workspace_path))} |`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const activeItems = activePlanItems(context, activeProtocols);
|
|
199
|
+
lines.push("", "## ✅ Plan", activeItems.length ? "| protocol | item | status | summary |" : "No active plan items.");
|
|
200
|
+
if (activeItems.length) {
|
|
201
|
+
lines.push("| --- | --- | --- | --- |");
|
|
202
|
+
for (const item of activeItems.slice(0, 8)) {
|
|
203
|
+
lines.push(`| ${cell(shortId(item.protocol))} | ${cell(item.id)} | ${statusIcon(item.status)} ${cell(item.status)} | ${cell(truncate(item.summary, 90))} |`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
lines.push("", "## 🔀 Merge Queue", recentQueue.length ? "| protocol | status | owner | note |" : "No merge queue jobs.");
|
|
207
|
+
if (recentQueue.length) {
|
|
208
|
+
lines.push("| --- | --- | --- | --- |");
|
|
209
|
+
for (const job of recentQueue) {
|
|
210
|
+
lines.push(`| ${cell(shortId(job.protocol_id))} | ${statusIcon(job.status)} ${cell(job.status)} | ${cell(job.claimed_by_session_id ?? "-")} | ${cell(truncate(job.last_reason ?? "", 110))} |`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
lines.push("", "## 🌿 Workspaces", worktrees.length || lanes.length ? "| kind | status | path |" : "No workspaces recorded.");
|
|
214
|
+
if (worktrees.length || lanes.length) {
|
|
215
|
+
lines.push("| --- | --- | --- |");
|
|
216
|
+
for (const lane of lanes) {
|
|
217
|
+
lines.push(`| lane:${cell(lane.name)} | ${statusIcon(lane.status)} ${cell(lane.status)} | ${cell(lane.workspace_path)} |`);
|
|
218
|
+
}
|
|
219
|
+
for (const worktree of worktrees.slice(0, 5)) {
|
|
220
|
+
lines.push(`| ${cell(shortId(worktree.protocol_id))} | ${statusIcon(worktree.status)} ${cell(worktree.status)} / ${cell(worktree.bootstrap_status)} | ${cell(worktree.worktree_path)} |`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
lines.push("", "## 🔒 Locks", locks.length ? "| lane | worker | status | expires |" : "No lane locks.");
|
|
224
|
+
if (locks.length) {
|
|
225
|
+
lines.push("| --- | --- | --- | --- |");
|
|
226
|
+
for (const lock of locks.slice(0, 5)) {
|
|
227
|
+
lines.push(`| ${cell(lock.lane)} | ${cell(lock.worker_id)} | ${statusIcon(lock.status)} ${cell(lock.status)} | ${cell(lock.expires_at)} |`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
lines.push("", "## 📜 Recent Protocols", recentProtocols.length ? "| protocol | stage/status | plan | updated |" : "No protocols registered.");
|
|
231
|
+
if (recentProtocols.length) {
|
|
232
|
+
lines.push("| --- | --- | --- | --- |");
|
|
233
|
+
for (const protocol of recentProtocols) {
|
|
234
|
+
const summary = planSummaryForProtocol(context, protocol.id);
|
|
235
|
+
lines.push(`| ${cell(shortId(protocol.id))} | ${statusIcon(protocol.status)} ${cell(protocol.stage)}/${cell(protocol.status)} | ${cell(`${summary.done}/${summary.total}`)} | ${cell(protocol.updated_at)} |`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
lines.push("", "## 🪝 Recent Hooks", hookEvents.length ? "| event | session | status | summary |" : "No hook events recorded.");
|
|
239
|
+
if (hookEvents.length) {
|
|
240
|
+
lines.push("| --- | --- | --- | --- |");
|
|
241
|
+
for (const event of hookEvents.slice(0, 3)) {
|
|
242
|
+
lines.push(`| ${cell(event.event_name)} | ${cell(event.session_id ? shortId(event.session_id) : "-")} | ${statusIcon(event.status)} ${cell(event.status)} | ${cell(truncate(event.sanitized_summary ?? "", 100))} |`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const traces = traceFiles(project.root, protocols);
|
|
246
|
+
lines.push("", "## 🧾 Recent Trace Files", traces.length ? "| protocol | file |" : "No trace files discovered.");
|
|
247
|
+
if (traces.length) {
|
|
248
|
+
lines.push("| --- | --- |");
|
|
249
|
+
for (const trace of traces.slice(0, 6)) {
|
|
250
|
+
lines.push(`| ${cell(shortId(trace.protocol))} | ${cell(compactPath(trace.file))} |`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
lines.push("", "## 🚧 Blockers And DEF", blockers.length || activeDefs.length ? "| protocol | kind | value |" : "No blockers or active DEF records.");
|
|
254
|
+
if (blockers.length || activeDefs.length) {
|
|
255
|
+
lines.push("| --- | --- | --- |");
|
|
256
|
+
for (const blocker of blockers) {
|
|
257
|
+
lines.push(`| ${cell(shortId(blocker.protocol))} | blocker | ${cell(truncate(describeJson(blocker.entry), 120))} |`);
|
|
258
|
+
}
|
|
259
|
+
for (const activeDef of activeDefs) {
|
|
260
|
+
lines.push(`| ${cell(shortId(activeDef.protocol))} | DEF | ${cell(truncate(describeJson(activeDef.entry), 120))} |`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return compactMarkdown(lines);
|
|
264
|
+
}
|
|
265
|
+
function renderGlobalDashboardMarkdown(context, output) {
|
|
266
|
+
const projects = context.db.all("SELECT * FROM projects WHERE status = 'active' ORDER BY updated_at DESC, root ASC");
|
|
267
|
+
const archivedProjects = context.db.all("SELECT * FROM projects WHERE status <> 'active' ORDER BY updated_at DESC, root ASC LIMIT 8");
|
|
268
|
+
const projectLookup = projects.concat(archivedProjects);
|
|
269
|
+
const recentJobs = context.db.all(`SELECT protocol_id, project_id, status, claimed_by_session_id, last_reason, updated_at
|
|
270
|
+
FROM merge_queue WHERE status IN ('merged', 'failed', 'requeued', 'cancelled')
|
|
271
|
+
ORDER BY updated_at DESC LIMIT 10`);
|
|
272
|
+
const lines = [
|
|
273
|
+
"# 🌐 dd-flow Global Dashboard",
|
|
274
|
+
"",
|
|
275
|
+
`**Updated:** ${context.now()}`,
|
|
276
|
+
`**Dashboard:** ${cell(output)}`,
|
|
277
|
+
"",
|
|
278
|
+
"## 🗂️ Projects",
|
|
279
|
+
projects.length ? "| project | active | sessions | queue | locks | dashboard |" : "No projects registered."
|
|
280
|
+
];
|
|
281
|
+
if (projects.length) {
|
|
282
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
283
|
+
for (const project of projects) {
|
|
284
|
+
const config = readProjectConfig(context, project.id);
|
|
285
|
+
const rootExists = fs.existsSync(project.root);
|
|
286
|
+
const activeProtocols = protocolsForProject(context, project.id).filter((protocol) => isActiveProtocolStatus(protocol.status));
|
|
287
|
+
const sessions = activeFlowSessionsForProject(context, project.id);
|
|
288
|
+
const queueSummary = queueStatusSummary(queueForProject(context, project.id));
|
|
289
|
+
const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? AND status = 'active'", [project.id]);
|
|
290
|
+
lines.push(`| ${rootExists ? "🟢" : "⚪"} ${cell(project.root)} | ${cell(rootExists ? activeProtocols.map((protocol) => `${shortId(protocol.id)}:${protocol.stage}`).join(", ") || "none" : "root_missing")} | ${cell(sessions.map((session) => `${session.flow_kind}:${session.worker_id ?? shortId(session.session_id)}`).join(", ") || "none")} | ${cell(queueSummary)} | ${cell(locks.map((lock) => `${lock.lane}:${lock.worker_id}`).join(", ") || "none")} | ${cell(dashboardMarkdownPath(project.root, config))} |`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
lines.push("", "## 🔀 Recent Merge Outcomes", recentJobs.length ? "| project | protocol | status | owner | reason | updated |" : "No completed or failed merge jobs.");
|
|
294
|
+
if (recentJobs.length) {
|
|
295
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
296
|
+
for (const job of recentJobs) {
|
|
297
|
+
const project = projectLookup.find((candidate) => candidate.id === job.project_id);
|
|
298
|
+
lines.push(`| ${cell(project ? compactPath(project.root) : job.project_id)} | ${cell(shortId(job.protocol_id))} | ${statusIcon(job.status)} ${cell(job.status)} | ${cell(job.claimed_by_session_id)} | ${cell(truncate(job.last_reason ?? "", 120))} | ${cell(job.updated_at)} |`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
lines.push("", "## 🗄️ Archived Projects", archivedProjects.length ? "| project | status | updated |" : "No archived projects.");
|
|
302
|
+
if (archivedProjects.length) {
|
|
303
|
+
lines.push("| --- | --- | --- |");
|
|
304
|
+
for (const project of archivedProjects) {
|
|
305
|
+
const rootExists = fs.existsSync(project.root);
|
|
306
|
+
lines.push(`| ${rootExists ? "🟢" : "⚪"} ${cell(project.root)} | ${cell(rootExists ? project.status : `${project.status} / root_missing`)} | ${cell(project.updated_at)} |`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return compactMarkdown(lines);
|
|
310
|
+
}
|
|
311
|
+
function protocolsForProject(context, projectId) {
|
|
312
|
+
return context.db.all(`SELECT id, status, stage, next_action, blockers_json, active_def_json, updated_at
|
|
313
|
+
FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
314
|
+
}
|
|
315
|
+
function planSummaryForProtocol(context, protocolId) {
|
|
316
|
+
const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocolId]);
|
|
317
|
+
if (!row) {
|
|
318
|
+
return { plan_id: "none", total: 0, done: 0, blocked: 0 };
|
|
319
|
+
}
|
|
320
|
+
const plan = JSON.parse(row.plan_json);
|
|
321
|
+
const items = Array.isArray(plan.items) ? plan.items : [];
|
|
322
|
+
return {
|
|
323
|
+
plan_id: plan.plan_id ?? "unknown",
|
|
324
|
+
total: items.length,
|
|
325
|
+
done: items.filter((item) => item.status === "done").length,
|
|
326
|
+
blocked: items.filter((item) => item.status === "blocked").length
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function activePlanItems(context, protocols) {
|
|
330
|
+
const items = [];
|
|
331
|
+
for (const protocol of protocols) {
|
|
332
|
+
const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocol.id]);
|
|
333
|
+
if (!row) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const plan = JSON.parse(row.plan_json);
|
|
337
|
+
for (const item of Array.isArray(plan.items) ? plan.items : []) {
|
|
338
|
+
if (["in_progress", "blocked"].includes(item.status ?? "")) {
|
|
339
|
+
items.push({
|
|
340
|
+
protocol: protocol.id,
|
|
341
|
+
id: item.id ?? "unknown",
|
|
342
|
+
status: item.status ?? "unknown",
|
|
343
|
+
summary: item.block_reason ?? item.summary ?? item.title ?? ""
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return items;
|
|
349
|
+
}
|
|
350
|
+
function traceFiles(projectRoot, protocols) {
|
|
351
|
+
const traces = [];
|
|
352
|
+
for (const protocol of protocols.slice(0, 10)) {
|
|
353
|
+
const traceDir = path.join(projectRoot, ".memory-bank", "protocol", protocol.id, "trace");
|
|
354
|
+
if (!fs.existsSync(traceDir)) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
for (const file of fs.readdirSync(traceDir).slice(0, 5)) {
|
|
358
|
+
traces.push({ protocol: protocol.id, file: path.join(traceDir, file) });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return traces.slice(0, 20);
|
|
362
|
+
}
|
|
363
|
+
function queueStatusSummary(queue) {
|
|
364
|
+
if (queue.length === 0) {
|
|
365
|
+
return "none";
|
|
366
|
+
}
|
|
367
|
+
const counts = new Map();
|
|
368
|
+
for (const job of queue.filter((entry) => isActiveQueueStatus(entry.status) || isQueueHistoryStatus(entry.status))) {
|
|
369
|
+
counts.set(job.status, (counts.get(job.status) ?? 0) + 1);
|
|
370
|
+
}
|
|
371
|
+
return [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(", ");
|
|
372
|
+
}
|
|
373
|
+
function workspaceForProtocol(worktrees, protocolId) {
|
|
374
|
+
return worktrees.find((worktree) => worktree.protocol_id === protocolId)?.worktree_path;
|
|
375
|
+
}
|
|
376
|
+
function shortId(value) {
|
|
377
|
+
return value.length > 34 ? `${value.slice(0, 31)}...` : value;
|
|
378
|
+
}
|
|
379
|
+
function compactPath(value) {
|
|
380
|
+
const home = process.env.HOME;
|
|
381
|
+
const compact = home && value.startsWith(home) ? `~${value.slice(home.length)}` : value;
|
|
382
|
+
const parts = compact.split(path.sep).filter(Boolean);
|
|
383
|
+
if (parts.length <= 4) {
|
|
384
|
+
return compact;
|
|
385
|
+
}
|
|
386
|
+
const prefix = compact.startsWith(path.sep) ? path.sep : "";
|
|
387
|
+
return `${prefix}${parts.slice(0, 2).join(path.sep)}${path.sep}...${path.sep}${parts.slice(-2).join(path.sep)}`;
|
|
388
|
+
}
|
|
389
|
+
function truncate(value, maxLength) {
|
|
390
|
+
return value.length > maxLength ? `${value.slice(0, Math.max(0, maxLength - 3))}...` : value;
|
|
391
|
+
}
|
|
392
|
+
function statusIcon(status) {
|
|
393
|
+
if (["active", "running", "ready", "ready_for_merge", "claimed", "in_progress"].includes(status))
|
|
394
|
+
return "🟢";
|
|
395
|
+
if (["blocked", "failed", "error"].includes(status))
|
|
396
|
+
return "🔴";
|
|
397
|
+
if (["waiting_user", "pending", "requeued", "stopping"].includes(status))
|
|
398
|
+
return "🟡";
|
|
399
|
+
if (["closed", "closed_local", "cancelled", "done", "merged", "released", "stopped"].includes(status))
|
|
400
|
+
return "✅";
|
|
401
|
+
if (["expired", "skipped"].includes(status))
|
|
402
|
+
return "⚪";
|
|
403
|
+
return "•";
|
|
404
|
+
}
|
|
405
|
+
function writeMarkdown(output, markdown) {
|
|
406
|
+
ensureDir(path.dirname(output));
|
|
407
|
+
fs.writeFileSync(output, markdown);
|
|
408
|
+
}
|
|
409
|
+
function jsonArray(value) {
|
|
410
|
+
try {
|
|
411
|
+
const parsed = JSON.parse(value);
|
|
412
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function describeJson(value) {
|
|
419
|
+
if (typeof value === "string") {
|
|
420
|
+
return value;
|
|
421
|
+
}
|
|
422
|
+
return JSON.stringify(value);
|
|
423
|
+
}
|
|
424
|
+
function compactMarkdown(lines) {
|
|
425
|
+
return `${lines.filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n")}\n`;
|
|
426
|
+
}
|
|
427
|
+
function cell(value) {
|
|
428
|
+
return redactString(String(value ?? ""))
|
|
429
|
+
.replace(/\|/g, "\\|")
|
|
430
|
+
.replace(/\n/g, " ");
|
|
431
|
+
}
|
|
432
|
+
function redactString(value) {
|
|
433
|
+
return value
|
|
434
|
+
.replace(/(token|secret|password|api[_-]?key)=\S+/gi, "$1=<redacted>")
|
|
435
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer <redacted>");
|
|
436
|
+
}
|