codecartographer-pi 0.6.0 → 0.8.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 +254 -208
- package/dist/core/dashboard.d.ts +36 -0
- package/dist/core/dashboard.js +526 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/utils.d.ts +13 -0
- package/dist/core/utils.js +27 -0
- package/dist/core/workspace.d.ts +1 -0
- package/dist/core/workspace.js +13 -1
- package/dist/extensions/codecarto/agent-rewriter.d.ts +13 -0
- package/dist/extensions/codecarto/agent-rewriter.js +21 -1
- package/dist/extensions/codecarto/auto-runner.d.ts +80 -0
- package/dist/extensions/codecarto/auto-runner.js +399 -0
- package/dist/extensions/codecarto/dashboard-flags.d.ts +6 -0
- package/dist/extensions/codecarto/dashboard-flags.js +17 -0
- package/dist/extensions/codecarto/dashboard-narrator.d.ts +8 -0
- package/dist/extensions/codecarto/dashboard-narrator.js +182 -0
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -0
- package/dist/extensions/codecarto/dashboard-writer.js +123 -0
- package/dist/extensions/codecarto/index.js +75 -192
- package/dist/extensions/codecarto/next-flags.d.ts +4 -0
- package/dist/extensions/codecarto/next-flags.js +19 -6
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { NormalizedStatus, PipelineFile } from "./types.ts";
|
|
2
|
+
import type { UsageFile } from "./usage.ts";
|
|
3
|
+
export declare const DASHBOARD_RELATIVE_PATH = "dashboard.html";
|
|
4
|
+
export declare const NARRATION_CACHE_RELATIVE_PATH = ".dashboard-narration.local.md";
|
|
5
|
+
export interface DashboardCloseoutEntry {
|
|
6
|
+
date: string;
|
|
7
|
+
phaseOrModule: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OutputAvailability {
|
|
11
|
+
primary?: {
|
|
12
|
+
path: string;
|
|
13
|
+
exists: boolean;
|
|
14
|
+
};
|
|
15
|
+
secondary: Array<{
|
|
16
|
+
path: string;
|
|
17
|
+
exists: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export interface DashboardNarration {
|
|
21
|
+
content: string;
|
|
22
|
+
generatedAt: string;
|
|
23
|
+
phaseCountAtGeneration: number;
|
|
24
|
+
}
|
|
25
|
+
export interface DashboardInputs {
|
|
26
|
+
status: NormalizedStatus;
|
|
27
|
+
pipeline: PipelineFile;
|
|
28
|
+
usage: UsageFile;
|
|
29
|
+
closeouts: DashboardCloseoutEntry[];
|
|
30
|
+
outputsPresent: Map<string, OutputAvailability>;
|
|
31
|
+
packageVersion: string;
|
|
32
|
+
generatedAt: string;
|
|
33
|
+
narration?: DashboardNarration;
|
|
34
|
+
}
|
|
35
|
+
export declare function renderDashboard(inputs: DashboardInputs): string;
|
|
36
|
+
export declare function escapeHtml(input: string): string;
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
// Pure HTML dashboard renderer for a CodeCartographer workspace. No I/O —
|
|
2
|
+
// the writer (extensions/codecarto/dashboard-writer.ts) gathers all inputs
|
|
3
|
+
// and calls renderDashboard() to produce a self-contained HTML document.
|
|
4
|
+
//
|
|
5
|
+
// Self-contained: embedded <style>, no JavaScript, no external assets.
|
|
6
|
+
// Works opened directly from a file:// URL. Light/dark via
|
|
7
|
+
// prefers-color-scheme. Mobile via a single max-width: 720px collapse rule.
|
|
8
|
+
//
|
|
9
|
+
// HTML safety: every disk-sourced value (phase IDs, owner notes, open-question
|
|
10
|
+
// descriptions, carry-forward target_phase, closeout filenames, paths inside
|
|
11
|
+
// href attributes) passes through escapeHtml. Href path segments also get
|
|
12
|
+
// URL-encoded before escaping.
|
|
13
|
+
import { formatMillis, formatTokenCount } from "./utils.js";
|
|
14
|
+
import { computePerPhaseTotals, computeTotals } from "./usage.js";
|
|
15
|
+
export const DASHBOARD_RELATIVE_PATH = "dashboard.html";
|
|
16
|
+
export const NARRATION_CACHE_RELATIVE_PATH = ".dashboard-narration.local.md";
|
|
17
|
+
const TIMELINE_VISIBLE_COUNT = 10;
|
|
18
|
+
export function renderDashboard(inputs) {
|
|
19
|
+
const sections = [
|
|
20
|
+
renderHeader(inputs),
|
|
21
|
+
inputs.narration ? renderNarration(inputs.narration, completedPhaseCount(inputs.status)) : "",
|
|
22
|
+
renderProgressBar(inputs.pipeline, inputs.status),
|
|
23
|
+
renderPhaseCards(inputs),
|
|
24
|
+
renderUsagePanel(inputs.usage),
|
|
25
|
+
renderActivityTimeline(inputs.usage.runs),
|
|
26
|
+
renderOpenQuestionsRollup(inputs.status),
|
|
27
|
+
renderCloseoutsList(inputs.closeouts),
|
|
28
|
+
renderFooter(inputs),
|
|
29
|
+
].filter(Boolean).join("\n");
|
|
30
|
+
const projectName = inputs.status.project_name || "CodeCartographer";
|
|
31
|
+
return [
|
|
32
|
+
"<!DOCTYPE html>",
|
|
33
|
+
`<html lang="en">`,
|
|
34
|
+
"<head>",
|
|
35
|
+
`<meta charset="utf-8">`,
|
|
36
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
37
|
+
`<title>${escapeHtml(projectName)} — CodeCartographer dashboard</title>`,
|
|
38
|
+
renderStyles(),
|
|
39
|
+
"</head>",
|
|
40
|
+
"<body>",
|
|
41
|
+
`<main class="cc-dashboard">`,
|
|
42
|
+
sections,
|
|
43
|
+
"</main>",
|
|
44
|
+
"</body>",
|
|
45
|
+
"</html>",
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
// ----------------------------------------------------------------------------
|
|
49
|
+
// Sections
|
|
50
|
+
// ----------------------------------------------------------------------------
|
|
51
|
+
function renderHeader(inputs) {
|
|
52
|
+
const { status, pipeline, packageVersion, generatedAt } = inputs;
|
|
53
|
+
const projectName = status.project_name || "(unnamed project)";
|
|
54
|
+
const pipelineLabel = pipeline.workflow_name || status.pipeline;
|
|
55
|
+
const currentPhase = status.current_phase || "—";
|
|
56
|
+
return [
|
|
57
|
+
`<header class="cc-header">`,
|
|
58
|
+
`<h1>${escapeHtml(projectName)}</h1>`,
|
|
59
|
+
`<dl class="cc-header-meta">`,
|
|
60
|
+
`<dt>Pipeline</dt><dd>${escapeHtml(pipelineLabel)}</dd>`,
|
|
61
|
+
`<dt>Current phase</dt><dd>${escapeHtml(currentPhase)}</dd>`,
|
|
62
|
+
`<dt>Status last updated</dt><dd>${escapeHtml(status.last_updated || "never")}</dd>`,
|
|
63
|
+
`<dt>Dashboard generated</dt><dd>${escapeHtml(generatedAt)}</dd>`,
|
|
64
|
+
`<dt>Package version</dt><dd>codecartographer-pi v${escapeHtml(packageVersion)}</dd>`,
|
|
65
|
+
`</dl>`,
|
|
66
|
+
`</header>`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
function renderNarration(narration, currentCompletedCount) {
|
|
70
|
+
const runsSince = Math.max(0, currentCompletedCount - narration.phaseCountAtGeneration);
|
|
71
|
+
const staleness = runsSince === 0
|
|
72
|
+
? "current"
|
|
73
|
+
: `${runsSince} run${runsSince === 1 ? "" : "s"} since`;
|
|
74
|
+
return [
|
|
75
|
+
`<section class="cc-narration" aria-label="Executive summary">`,
|
|
76
|
+
`<h2>Executive summary</h2>`,
|
|
77
|
+
`<p class="cc-narration-meta">Narrated ${escapeHtml(narration.generatedAt)} · ${escapeHtml(staleness)}.</p>`,
|
|
78
|
+
`<div class="cc-narration-body">`,
|
|
79
|
+
// The narration body is LLM-generated Markdown. We do not parse Markdown
|
|
80
|
+
// to HTML here (zero-dep policy); we escape it and render in a <pre>
|
|
81
|
+
// so the user gets readable output without an XSS vector. A future
|
|
82
|
+
// upgrade could swap in a tiny Markdown renderer.
|
|
83
|
+
`<pre>${escapeHtml(narration.content)}</pre>`,
|
|
84
|
+
`</div>`,
|
|
85
|
+
`</section>`,
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
function renderProgressBar(pipeline, status) {
|
|
89
|
+
const items = pipeline.phase_order.map((phaseId) => {
|
|
90
|
+
const state = phaseRenderState(status, phaseId);
|
|
91
|
+
const purpose = pipeline.phases.find((p) => p.id === phaseId)?.purpose ?? "";
|
|
92
|
+
return [
|
|
93
|
+
`<li class="cc-progress-item cc-state-${state}" title="${escapeAttr(purpose)}">`,
|
|
94
|
+
`<span class="cc-progress-id">${escapeHtml(phaseId)}</span>`,
|
|
95
|
+
`<span class="cc-progress-badge">${escapeHtml(state)}</span>`,
|
|
96
|
+
`</li>`,
|
|
97
|
+
].join("");
|
|
98
|
+
}).join("\n");
|
|
99
|
+
return [
|
|
100
|
+
`<section class="cc-progress" aria-label="Pipeline progress">`,
|
|
101
|
+
`<h2>Pipeline progress</h2>`,
|
|
102
|
+
`<ol class="cc-progress-list">`,
|
|
103
|
+
items,
|
|
104
|
+
`</ol>`,
|
|
105
|
+
`</section>`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
function renderPhaseCards(inputs) {
|
|
109
|
+
const { status, pipeline, usage, outputsPresent } = inputs;
|
|
110
|
+
const perPhaseLastRun = lastRunPerPhase(usage.runs);
|
|
111
|
+
const cards = pipeline.phase_order.map((phaseId) => {
|
|
112
|
+
const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
|
|
113
|
+
const phaseState = status.phases[phaseId];
|
|
114
|
+
const renderState = phaseRenderState(status, phaseId);
|
|
115
|
+
const outputs = outputsPresent.get(phaseId);
|
|
116
|
+
const lastRun = perPhaseLastRun.get(phaseId);
|
|
117
|
+
const open = renderState === "running" || renderState === "current";
|
|
118
|
+
const detailsAttr = open ? " open" : "";
|
|
119
|
+
return [
|
|
120
|
+
`<details class="cc-phase-card cc-state-${renderState}"${detailsAttr}>`,
|
|
121
|
+
`<summary>`,
|
|
122
|
+
`<span class="cc-phase-id">${escapeHtml(phaseId)}</span>`,
|
|
123
|
+
`<span class="cc-phase-badge">${escapeHtml(renderState)}</span>`,
|
|
124
|
+
`</summary>`,
|
|
125
|
+
renderPhasePurpose(phaseDef),
|
|
126
|
+
renderPhaseOutputs(phaseDef, outputs),
|
|
127
|
+
renderPhaseOpenQuestions(phaseState),
|
|
128
|
+
renderPhaseCarryForward(phaseState),
|
|
129
|
+
renderPhaseOwnerNotes(phaseState),
|
|
130
|
+
renderPhaseLastRun(lastRun),
|
|
131
|
+
`</details>`,
|
|
132
|
+
].filter(Boolean).join("\n");
|
|
133
|
+
}).join("\n");
|
|
134
|
+
return [
|
|
135
|
+
`<section class="cc-phases" aria-label="Per-phase status">`,
|
|
136
|
+
`<h2>Phases</h2>`,
|
|
137
|
+
cards,
|
|
138
|
+
`</section>`,
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
141
|
+
function renderPhasePurpose(phase) {
|
|
142
|
+
if (!phase?.purpose)
|
|
143
|
+
return "";
|
|
144
|
+
return `<p class="cc-phase-purpose">${escapeHtml(phase.purpose)}</p>`;
|
|
145
|
+
}
|
|
146
|
+
function renderPhaseOutputs(phase, outputs) {
|
|
147
|
+
if (!phase)
|
|
148
|
+
return "";
|
|
149
|
+
const lines = [];
|
|
150
|
+
if (phase.primary_output) {
|
|
151
|
+
const present = outputs?.primary?.exists ?? false;
|
|
152
|
+
const href = relativeHref(phase.primary_output);
|
|
153
|
+
lines.push(present
|
|
154
|
+
? `<li><a href="${escapeAttr(href)}">${escapeHtml(phase.primary_output)}</a> <span class="cc-tag cc-tag-present">primary</span></li>`
|
|
155
|
+
: `<li><span class="cc-tag cc-tag-missing">primary (missing)</span> ${escapeHtml(phase.primary_output)}</li>`);
|
|
156
|
+
}
|
|
157
|
+
for (const sec of phase.secondary_outputs ?? []) {
|
|
158
|
+
const present = outputs?.secondary.find((s) => s.path === sec.path)?.exists ?? false;
|
|
159
|
+
const href = relativeHref(sec.path);
|
|
160
|
+
lines.push(present
|
|
161
|
+
? `<li><a href="${escapeAttr(href)}">${escapeHtml(sec.path)}</a> <span class="cc-tag">secondary</span></li>`
|
|
162
|
+
: `<li><span class="cc-tag cc-tag-missing">secondary (missing)</span> ${escapeHtml(sec.path)}</li>`);
|
|
163
|
+
}
|
|
164
|
+
if (lines.length === 0)
|
|
165
|
+
return "";
|
|
166
|
+
return `<div class="cc-phase-section"><h3>Outputs</h3><ul class="cc-output-list">${lines.join("")}</ul></div>`;
|
|
167
|
+
}
|
|
168
|
+
function renderPhaseOpenQuestions(phaseState) {
|
|
169
|
+
const items = phaseState?.open_questions ?? [];
|
|
170
|
+
if (items.length === 0)
|
|
171
|
+
return "";
|
|
172
|
+
return [
|
|
173
|
+
`<div class="cc-phase-section">`,
|
|
174
|
+
`<h3>Open questions (${items.length})</h3>`,
|
|
175
|
+
`<ul class="cc-question-list">`,
|
|
176
|
+
items.map(renderOpenQuestion).join(""),
|
|
177
|
+
`</ul>`,
|
|
178
|
+
`</div>`,
|
|
179
|
+
].join("");
|
|
180
|
+
}
|
|
181
|
+
function renderOpenQuestion(q) {
|
|
182
|
+
const kind = q.kind ? `<span class="cc-tag">${escapeHtml(String(q.kind))}</span> ` : "";
|
|
183
|
+
const desc = escapeHtml(q.description ?? "(no description)");
|
|
184
|
+
const reason = q.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(q.deferred_reason)}</div>` : "";
|
|
185
|
+
return `<li>${kind}${desc}${reason}</li>`;
|
|
186
|
+
}
|
|
187
|
+
function renderPhaseCarryForward(phaseState) {
|
|
188
|
+
const items = phaseState?.carry_forward ?? [];
|
|
189
|
+
if (items.length === 0)
|
|
190
|
+
return "";
|
|
191
|
+
return [
|
|
192
|
+
`<div class="cc-phase-section">`,
|
|
193
|
+
`<h3>Carry-forward (${items.length})</h3>`,
|
|
194
|
+
`<ul class="cc-carry-list">`,
|
|
195
|
+
items.map(renderCarryForward).join(""),
|
|
196
|
+
`</ul>`,
|
|
197
|
+
`</div>`,
|
|
198
|
+
].join("");
|
|
199
|
+
}
|
|
200
|
+
function renderCarryForward(c) {
|
|
201
|
+
const target = c.target_phase ? `<span class="cc-tag cc-tag-target">→ ${escapeHtml(c.target_phase)}</span> ` : "";
|
|
202
|
+
const kind = c.kind ? `<span class="cc-tag">${escapeHtml(String(c.kind))}</span> ` : "";
|
|
203
|
+
const desc = escapeHtml(c.description ?? "(no description)");
|
|
204
|
+
const reason = c.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(c.deferred_reason)}</div>` : "";
|
|
205
|
+
return `<li>${target}${kind}${desc}${reason}</li>`;
|
|
206
|
+
}
|
|
207
|
+
function renderPhaseOwnerNotes(phaseState) {
|
|
208
|
+
const notes = phaseState?.owner_notes ?? [];
|
|
209
|
+
if (notes.length === 0)
|
|
210
|
+
return "";
|
|
211
|
+
return [
|
|
212
|
+
`<div class="cc-phase-section">`,
|
|
213
|
+
`<h3>Owner notes</h3>`,
|
|
214
|
+
`<ul class="cc-notes-list">`,
|
|
215
|
+
notes.map((n) => `<li>${escapeHtml(n)}</li>`).join(""),
|
|
216
|
+
`</ul>`,
|
|
217
|
+
`</div>`,
|
|
218
|
+
].join("");
|
|
219
|
+
}
|
|
220
|
+
function renderPhaseLastRun(run) {
|
|
221
|
+
if (!run)
|
|
222
|
+
return "";
|
|
223
|
+
const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
|
|
224
|
+
return [
|
|
225
|
+
`<div class="cc-phase-section">`,
|
|
226
|
+
`<h3>Last run</h3>`,
|
|
227
|
+
`<dl class="cc-run-meta">`,
|
|
228
|
+
`<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`,
|
|
229
|
+
`<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`,
|
|
230
|
+
`<dt>Turns</dt><dd>${run.turn_count}</dd>`,
|
|
231
|
+
`<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`,
|
|
232
|
+
`<dt>Tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
|
|
233
|
+
`<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`,
|
|
234
|
+
`</dl>`,
|
|
235
|
+
`</div>`,
|
|
236
|
+
].join("");
|
|
237
|
+
}
|
|
238
|
+
function renderUsagePanel(usage) {
|
|
239
|
+
if (usage.runs.length === 0) {
|
|
240
|
+
return [
|
|
241
|
+
`<section class="cc-usage" aria-label="Token usage">`,
|
|
242
|
+
`<h2>Usage</h2>`,
|
|
243
|
+
`<p class="cc-empty">No phase runs recorded yet.</p>`,
|
|
244
|
+
`</section>`,
|
|
245
|
+
].join("\n");
|
|
246
|
+
}
|
|
247
|
+
const totals = computeTotals(usage);
|
|
248
|
+
const perPhase = computePerPhaseTotals(usage);
|
|
249
|
+
const rows = [...perPhase.entries()].map(([phaseId, t]) => {
|
|
250
|
+
const tokensTotal = t.tokens.input + t.tokens.output;
|
|
251
|
+
return [
|
|
252
|
+
`<tr>`,
|
|
253
|
+
`<td>${escapeHtml(phaseId)}</td>`,
|
|
254
|
+
`<td>${t.runs}</td>`,
|
|
255
|
+
`<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
|
|
256
|
+
`<td>${t.tool_uses}</td>`,
|
|
257
|
+
`<td>${escapeHtml(formatMillis(t.duration_ms))}</td>`,
|
|
258
|
+
`</tr>`,
|
|
259
|
+
].join("");
|
|
260
|
+
}).join("");
|
|
261
|
+
return [
|
|
262
|
+
`<section class="cc-usage" aria-label="Token usage">`,
|
|
263
|
+
`<h2>Usage</h2>`,
|
|
264
|
+
`<dl class="cc-usage-totals">`,
|
|
265
|
+
renderUsageTotalsList(totals),
|
|
266
|
+
`</dl>`,
|
|
267
|
+
`<table class="cc-usage-table">`,
|
|
268
|
+
`<thead><tr><th>Phase</th><th>Runs</th><th>Tokens</th><th>Tools</th><th>Duration</th></tr></thead>`,
|
|
269
|
+
`<tbody>${rows}</tbody>`,
|
|
270
|
+
`</table>`,
|
|
271
|
+
`</section>`,
|
|
272
|
+
].join("\n");
|
|
273
|
+
}
|
|
274
|
+
function renderUsageTotalsList(totals) {
|
|
275
|
+
const tokensTotal = totals.tokens.input + totals.tokens.output;
|
|
276
|
+
return [
|
|
277
|
+
`<dt>Total runs</dt><dd>${totals.runs}</dd>`,
|
|
278
|
+
`<dt>Tokens (in / out / cache)</dt><dd>${escapeHtml(formatTokenCount(totals.tokens.input))} / ${escapeHtml(formatTokenCount(totals.tokens.output))} / ${escapeHtml(formatTokenCount(totals.tokens.cache_write))}</dd>`,
|
|
279
|
+
`<dt>Total tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
|
|
280
|
+
`<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`,
|
|
281
|
+
`<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`,
|
|
282
|
+
].join("");
|
|
283
|
+
}
|
|
284
|
+
function renderActivityTimeline(runs) {
|
|
285
|
+
if (runs.length === 0)
|
|
286
|
+
return "";
|
|
287
|
+
const sorted = [...runs].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
|
|
288
|
+
const visible = sorted.slice(0, TIMELINE_VISIBLE_COUNT);
|
|
289
|
+
const overflow = sorted.slice(TIMELINE_VISIBLE_COUNT);
|
|
290
|
+
const rowOf = (run) => {
|
|
291
|
+
const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
|
|
292
|
+
const sessionCell = run.session_file
|
|
293
|
+
? `<a href="${escapeAttr(run.session_file)}">session</a>`
|
|
294
|
+
: "—";
|
|
295
|
+
return [
|
|
296
|
+
`<tr>`,
|
|
297
|
+
`<td>${escapeHtml(run.timestamp)}</td>`,
|
|
298
|
+
`<td>${escapeHtml(run.phase)}</td>`,
|
|
299
|
+
`<td>${escapeHtml(run.status)}</td>`,
|
|
300
|
+
`<td>${run.turn_count}</td>`,
|
|
301
|
+
`<td>${run.tool_uses}</td>`,
|
|
302
|
+
`<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
|
|
303
|
+
`<td>${escapeHtml(formatMillis(run.duration_ms))}</td>`,
|
|
304
|
+
`<td>${sessionCell}</td>`,
|
|
305
|
+
`</tr>`,
|
|
306
|
+
].join("");
|
|
307
|
+
};
|
|
308
|
+
const visibleRows = visible.map(rowOf).join("");
|
|
309
|
+
const overflowBlock = overflow.length === 0 ? "" : [
|
|
310
|
+
`<details class="cc-timeline-older">`,
|
|
311
|
+
`<summary>Older runs (${overflow.length})</summary>`,
|
|
312
|
+
`<table class="cc-timeline-table">`,
|
|
313
|
+
`<tbody>${overflow.map(rowOf).join("")}</tbody>`,
|
|
314
|
+
`</table>`,
|
|
315
|
+
`</details>`,
|
|
316
|
+
].join("\n");
|
|
317
|
+
return [
|
|
318
|
+
`<section class="cc-timeline" aria-label="Activity timeline">`,
|
|
319
|
+
`<h2>Activity timeline</h2>`,
|
|
320
|
+
`<table class="cc-timeline-table">`,
|
|
321
|
+
`<thead><tr><th>When</th><th>Phase</th><th>Status</th><th>⟳</th><th>Tools</th><th>Tokens</th><th>Duration</th><th>Session</th></tr></thead>`,
|
|
322
|
+
`<tbody>${visibleRows}</tbody>`,
|
|
323
|
+
`</table>`,
|
|
324
|
+
overflowBlock,
|
|
325
|
+
`</section>`,
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
function renderOpenQuestionsRollup(status) {
|
|
329
|
+
const buckets = [];
|
|
330
|
+
for (const [phaseId, phaseState] of Object.entries(status.phases)) {
|
|
331
|
+
if ((phaseState.open_questions ?? []).length > 0) {
|
|
332
|
+
buckets.push({ phaseId, questions: phaseState.open_questions });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (buckets.length === 0)
|
|
336
|
+
return "";
|
|
337
|
+
const sections = buckets.map(({ phaseId, questions }) => [
|
|
338
|
+
`<div class="cc-rollup-phase">`,
|
|
339
|
+
`<h3>${escapeHtml(phaseId)} (${questions.length})</h3>`,
|
|
340
|
+
`<ul class="cc-question-list">`,
|
|
341
|
+
questions.map(renderOpenQuestion).join(""),
|
|
342
|
+
`</ul>`,
|
|
343
|
+
`</div>`,
|
|
344
|
+
].join("")).join("\n");
|
|
345
|
+
return [
|
|
346
|
+
`<section class="cc-rollup" aria-label="Open questions roll-up">`,
|
|
347
|
+
`<h2>Open questions</h2>`,
|
|
348
|
+
sections,
|
|
349
|
+
`</section>`,
|
|
350
|
+
].join("\n");
|
|
351
|
+
}
|
|
352
|
+
function renderCloseoutsList(closeouts) {
|
|
353
|
+
if (closeouts.length === 0) {
|
|
354
|
+
return [
|
|
355
|
+
`<section class="cc-closeouts" aria-label="Closeouts">`,
|
|
356
|
+
`<h2>Closeouts</h2>`,
|
|
357
|
+
`<p class="cc-empty">No closeouts yet.</p>`,
|
|
358
|
+
`</section>`,
|
|
359
|
+
].join("\n");
|
|
360
|
+
}
|
|
361
|
+
const sorted = [...closeouts].sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
362
|
+
const rows = sorted.map((c) => {
|
|
363
|
+
const href = relativeHref(`closeouts/${c.fileName}`);
|
|
364
|
+
return [
|
|
365
|
+
`<li>`,
|
|
366
|
+
`<span class="cc-closeout-date">${escapeHtml(c.date)}</span> `,
|
|
367
|
+
`<span class="cc-closeout-phase">${escapeHtml(c.phaseOrModule)}</span> `,
|
|
368
|
+
`— <a href="${escapeAttr(href)}">${escapeHtml(c.fileName)}</a>`,
|
|
369
|
+
`</li>`,
|
|
370
|
+
].join("");
|
|
371
|
+
}).join("");
|
|
372
|
+
return [
|
|
373
|
+
`<section class="cc-closeouts" aria-label="Closeouts">`,
|
|
374
|
+
`<h2>Closeouts</h2>`,
|
|
375
|
+
`<ul class="cc-closeouts-list">${rows}</ul>`,
|
|
376
|
+
`</section>`,
|
|
377
|
+
].join("\n");
|
|
378
|
+
}
|
|
379
|
+
function renderFooter(inputs) {
|
|
380
|
+
return [
|
|
381
|
+
`<footer class="cc-footer">`,
|
|
382
|
+
`<p>Generated by codecartographer-pi v${escapeHtml(inputs.packageVersion)} at ${escapeHtml(inputs.generatedAt)}.</p>`,
|
|
383
|
+
`<p class="cc-footer-hint">Regenerate via <code>/codecarto-dashboard</code> · narrate via <code>/codecarto-dashboard --narrate</code>.</p>`,
|
|
384
|
+
`</footer>`,
|
|
385
|
+
].join("\n");
|
|
386
|
+
}
|
|
387
|
+
// ----------------------------------------------------------------------------
|
|
388
|
+
// Helpers
|
|
389
|
+
// ----------------------------------------------------------------------------
|
|
390
|
+
function phaseRenderState(status, phaseId) {
|
|
391
|
+
const phaseStatus = status.phases[phaseId]?.status;
|
|
392
|
+
if (phaseStatus === "complete")
|
|
393
|
+
return "complete";
|
|
394
|
+
if (status.current_phase === phaseId)
|
|
395
|
+
return "current";
|
|
396
|
+
return "pending";
|
|
397
|
+
}
|
|
398
|
+
function completedPhaseCount(status) {
|
|
399
|
+
return Object.values(status.phases).filter((p) => p.status === "complete").length;
|
|
400
|
+
}
|
|
401
|
+
function lastRunPerPhase(runs) {
|
|
402
|
+
const out = new Map();
|
|
403
|
+
for (const r of runs) {
|
|
404
|
+
const existing = out.get(r.phase);
|
|
405
|
+
if (!existing || existing.timestamp < r.timestamp)
|
|
406
|
+
out.set(r.phase, r);
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function relativeHref(path) {
|
|
411
|
+
// The dashboard lives at <workspaceDir>/dashboard.html. Workspace-relative
|
|
412
|
+
// paths in pipeline definitions and closeouts are relative to the
|
|
413
|
+
// workspaceDir, so they resolve from the dashboard's URL directly.
|
|
414
|
+
return path.split("/").map((seg) => encodeURIComponent(seg)).join("/");
|
|
415
|
+
}
|
|
416
|
+
export function escapeHtml(input) {
|
|
417
|
+
return input
|
|
418
|
+
.replace(/&/g, "&")
|
|
419
|
+
.replace(/</g, "<")
|
|
420
|
+
.replace(/>/g, ">")
|
|
421
|
+
.replace(/"/g, """)
|
|
422
|
+
.replace(/'/g, "'");
|
|
423
|
+
}
|
|
424
|
+
function escapeAttr(input) {
|
|
425
|
+
return escapeHtml(input);
|
|
426
|
+
}
|
|
427
|
+
// ----------------------------------------------------------------------------
|
|
428
|
+
// Styles
|
|
429
|
+
// ----------------------------------------------------------------------------
|
|
430
|
+
function renderStyles() {
|
|
431
|
+
return `<style>
|
|
432
|
+
:root {
|
|
433
|
+
--s-1: 4px; --s-2: 8px; --s-3: 16px; --s-4: 24px; --s-5: 40px;
|
|
434
|
+
--fg: #1a1a1a; --fg-dim: #5a5a5a; --bg: #fafafa; --bg-card: #ffffff;
|
|
435
|
+
--border: #e3e3e3; --accent: #2563eb; --accent-fg: #ffffff;
|
|
436
|
+
--status-pending: #94a3b8; --status-current: #2563eb; --status-running: #f59e0b;
|
|
437
|
+
--status-complete: #16a34a; --status-error: #dc2626;
|
|
438
|
+
--code-bg: #f1f5f9;
|
|
439
|
+
}
|
|
440
|
+
@media (prefers-color-scheme: dark) {
|
|
441
|
+
:root {
|
|
442
|
+
--fg: #f1f5f9; --fg-dim: #94a3b8; --bg: #0f172a; --bg-card: #1e293b;
|
|
443
|
+
--border: #334155; --accent: #60a5fa; --accent-fg: #0f172a;
|
|
444
|
+
--status-pending: #64748b; --status-current: #60a5fa; --status-running: #fbbf24;
|
|
445
|
+
--status-complete: #4ade80; --status-error: #f87171;
|
|
446
|
+
--code-bg: #1e293b;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
body {
|
|
450
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
451
|
+
background: var(--bg); color: var(--fg); margin: 0; padding: var(--s-4);
|
|
452
|
+
line-height: 1.5; font-size: 15px;
|
|
453
|
+
}
|
|
454
|
+
.cc-dashboard { max-width: 1100px; margin: 0 auto; }
|
|
455
|
+
.cc-header h1 { margin: 0 0 var(--s-3) 0; font-size: 28px; }
|
|
456
|
+
.cc-header-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-4) 0; }
|
|
457
|
+
.cc-header-meta dt { color: var(--fg-dim); font-weight: 500; }
|
|
458
|
+
.cc-header-meta dd { margin: 0; }
|
|
459
|
+
h2 { font-size: 18px; margin: var(--s-4) 0 var(--s-2) 0; padding-bottom: var(--s-1); border-bottom: 1px solid var(--border); }
|
|
460
|
+
h3 { font-size: 14px; margin: var(--s-2) 0 var(--s-1) 0; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
461
|
+
a { color: var(--accent); text-decoration: none; }
|
|
462
|
+
a:hover { text-decoration: underline; }
|
|
463
|
+
code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
|
|
464
|
+
section { margin-bottom: var(--s-4); }
|
|
465
|
+
.cc-empty { color: var(--fg-dim); font-style: italic; }
|
|
466
|
+
|
|
467
|
+
/* Narration */
|
|
468
|
+
.cc-narration { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: var(--s-3); }
|
|
469
|
+
.cc-narration-meta { color: var(--fg-dim); font-size: 13px; margin: 0 0 var(--s-2) 0; }
|
|
470
|
+
.cc-narration-body pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; font-family: inherit; font-size: 14px; line-height: 1.6; }
|
|
471
|
+
|
|
472
|
+
/* Progress bar */
|
|
473
|
+
.cc-progress-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: var(--s-1); }
|
|
474
|
+
.cc-progress-item { flex: 1 1 120px; padding: var(--s-2); border: 1px solid var(--border); border-radius: 4px; background: var(--bg-card); display: flex; flex-direction: column; gap: 2px; }
|
|
475
|
+
.cc-progress-id { font-weight: 600; }
|
|
476
|
+
.cc-progress-badge { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
477
|
+
.cc-state-complete .cc-progress-badge, .cc-state-complete .cc-phase-badge { color: var(--status-complete); }
|
|
478
|
+
.cc-state-current .cc-progress-badge, .cc-state-current .cc-phase-badge { color: var(--status-current); font-weight: 600; }
|
|
479
|
+
.cc-state-running .cc-progress-badge, .cc-state-running .cc-phase-badge { color: var(--status-running); font-weight: 600; }
|
|
480
|
+
.cc-state-pending .cc-progress-badge, .cc-state-pending .cc-phase-badge { color: var(--status-pending); }
|
|
481
|
+
.cc-state-current { border-left: 3px solid var(--status-current); }
|
|
482
|
+
|
|
483
|
+
/* Phase cards */
|
|
484
|
+
.cc-phase-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 0; margin-bottom: var(--s-2); }
|
|
485
|
+
.cc-phase-card summary { padding: var(--s-2) var(--s-3); cursor: pointer; display: flex; gap: var(--s-3); align-items: baseline; }
|
|
486
|
+
.cc-phase-card[open] summary { border-bottom: 1px solid var(--border); }
|
|
487
|
+
.cc-phase-id { font-weight: 600; font-size: 16px; }
|
|
488
|
+
.cc-phase-purpose { padding: var(--s-2) var(--s-3) 0; color: var(--fg-dim); }
|
|
489
|
+
.cc-phase-section { padding: var(--s-2) var(--s-3); }
|
|
490
|
+
.cc-phase-section ul { margin: 0; padding-left: var(--s-3); }
|
|
491
|
+
.cc-output-list li, .cc-question-list li, .cc-carry-list li, .cc-notes-list li { margin: var(--s-1) 0; }
|
|
492
|
+
.cc-question-reason { color: var(--fg-dim); font-size: 13px; margin-left: var(--s-2); }
|
|
493
|
+
|
|
494
|
+
/* Tags */
|
|
495
|
+
.cc-tag { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: 11px; background: var(--code-bg); color: var(--fg-dim); border: 1px solid var(--border); }
|
|
496
|
+
.cc-tag-present { background: color-mix(in srgb, var(--status-complete) 15%, transparent); color: var(--status-complete); border-color: var(--status-complete); }
|
|
497
|
+
.cc-tag-missing { background: color-mix(in srgb, var(--status-error) 15%, transparent); color: var(--status-error); border-color: var(--status-error); }
|
|
498
|
+
.cc-tag-target { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border-color: var(--accent); }
|
|
499
|
+
|
|
500
|
+
/* Run meta dl */
|
|
501
|
+
.cc-run-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0; }
|
|
502
|
+
.cc-run-meta dt { color: var(--fg-dim); font-size: 13px; }
|
|
503
|
+
.cc-run-meta dd { margin: 0; font-size: 13px; }
|
|
504
|
+
|
|
505
|
+
/* Usage */
|
|
506
|
+
.cc-usage-totals { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-3) 0; }
|
|
507
|
+
.cc-usage-totals dt { color: var(--fg-dim); }
|
|
508
|
+
.cc-usage-totals dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
509
|
+
.cc-usage-table, .cc-timeline-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
510
|
+
.cc-usage-table th, .cc-usage-table td, .cc-timeline-table th, .cc-timeline-table td { padding: var(--s-1) var(--s-2); text-align: left; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
|
511
|
+
.cc-usage-table th, .cc-timeline-table th { color: var(--fg-dim); font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
|
|
512
|
+
.cc-timeline-older { margin-top: var(--s-2); }
|
|
513
|
+
|
|
514
|
+
/* Footer */
|
|
515
|
+
.cc-footer { margin-top: var(--s-5); padding-top: var(--s-3); border-top: 1px solid var(--border); color: var(--fg-dim); font-size: 13px; }
|
|
516
|
+
.cc-footer p { margin: var(--s-1) 0; }
|
|
517
|
+
|
|
518
|
+
/* Mobile */
|
|
519
|
+
@media (max-width: 720px) {
|
|
520
|
+
body { padding: var(--s-3); font-size: 14px; }
|
|
521
|
+
.cc-progress-list { flex-direction: column; }
|
|
522
|
+
.cc-header-meta, .cc-run-meta, .cc-usage-totals { grid-template-columns: 1fr; }
|
|
523
|
+
.cc-header-meta dt, .cc-run-meta dt, .cc-usage-totals dt { margin-top: var(--s-1); }
|
|
524
|
+
}
|
|
525
|
+
</style>`;
|
|
526
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/utils.d.ts
CHANGED
|
@@ -6,3 +6,16 @@ export declare function isWithinPath(path: string, root: string): boolean;
|
|
|
6
6
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
7
7
|
export declare function uniqueStrings(items: string[]): string[];
|
|
8
8
|
export declare function dateOnly(timestamp: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Format an integer as `2.50M` / `2.3k` / `500`. Used by the HTML dashboard
|
|
11
|
+
* for compact numeric cells. The widget and notify paths have their own
|
|
12
|
+
* formatters that include " tokens" / unit suffixes inline; this helper is
|
|
13
|
+
* deliberately suffix-free so callers attach units in surrounding markup.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatTokenCount(count: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Format a millisecond duration as `2m30s` / `1.5s` / `500ms`. Matches the
|
|
18
|
+
* extension widget's `formatDuration` shape; promoted to `core/` so the
|
|
19
|
+
* dashboard renderer can reuse without crossing the core/extensions boundary.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatMillis(ms: number): string;
|
package/dist/core/utils.js
CHANGED
|
@@ -44,3 +44,30 @@ export function uniqueStrings(items) {
|
|
|
44
44
|
export function dateOnly(timestamp) {
|
|
45
45
|
return timestamp.slice(0, 10);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Format an integer as `2.50M` / `2.3k` / `500`. Used by the HTML dashboard
|
|
49
|
+
* for compact numeric cells. The widget and notify paths have their own
|
|
50
|
+
* formatters that include " tokens" / unit suffixes inline; this helper is
|
|
51
|
+
* deliberately suffix-free so callers attach units in surrounding markup.
|
|
52
|
+
*/
|
|
53
|
+
export function formatTokenCount(count) {
|
|
54
|
+
if (count >= 1_000_000)
|
|
55
|
+
return `${(count / 1_000_000).toFixed(2)}M`;
|
|
56
|
+
if (count >= 1_000)
|
|
57
|
+
return `${(count / 1_000).toFixed(1)}k`;
|
|
58
|
+
return `${count}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Format a millisecond duration as `2m30s` / `1.5s` / `500ms`. Matches the
|
|
62
|
+
* extension widget's `formatDuration` shape; promoted to `core/` so the
|
|
63
|
+
* dashboard renderer can reuse without crossing the core/extensions boundary.
|
|
64
|
+
*/
|
|
65
|
+
export function formatMillis(ms) {
|
|
66
|
+
if (ms < 1000)
|
|
67
|
+
return `${ms}ms`;
|
|
68
|
+
if (ms < 60_000)
|
|
69
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
70
|
+
const minutes = Math.floor(ms / 60_000);
|
|
71
|
+
const seconds = Math.floor((ms % 60_000) / 1000);
|
|
72
|
+
return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
|
|
73
|
+
}
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WorkspaceState } from "./types.ts";
|
|
2
2
|
export declare const packagedWorkspaceDir: string;
|
|
3
|
+
export declare const PACKAGE_VERSION: string;
|
|
3
4
|
export declare function getWorkspaceState(cwd: string): Promise<WorkspaceState | null>;
|
|
4
5
|
export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<{
|
|
5
6
|
state: WorkspaceState;
|
package/dist/core/workspace.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (so the MCP server and Pi can both copy from it on /codecarto-init), loads
|
|
3
3
|
// + normalizes the per-project workspace state from disk, and provides the
|
|
4
4
|
// atomic status-update primitive used by /codecarto-complete.
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { appendFile, rename, writeFile } from "node:fs/promises";
|
|
7
7
|
import { dirname, join, relative } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -30,6 +30,18 @@ const packageRoot = findPackageRoot(coreDir);
|
|
|
30
30
|
// Path to the packaged framework template directory. Wrappers copy this on
|
|
31
31
|
// /codecarto-init.
|
|
32
32
|
export const packagedWorkspaceDir = join(packageRoot, ".codecarto");
|
|
33
|
+
// Resolved at module-load time from the same package.json that findPackageRoot
|
|
34
|
+
// located. Used by the HTML dashboard renderer for the footer; cheap to read
|
|
35
|
+
// once since startup is already paying for findPackageRoot.
|
|
36
|
+
export const PACKAGE_VERSION = (() => {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
39
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "0.0.0";
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
33
45
|
export async function getWorkspaceState(cwd) {
|
|
34
46
|
const workspaceDir = join(cwd, ".codecarto");
|
|
35
47
|
const statusPath = join(workspaceDir, "workflow", "status.yaml");
|