@dyzsasd/dev-loop 0.22.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 +55 -0
- package/dist/agentops.js +551 -0
- package/dist/channel.js +226 -0
- package/dist/channelstore.js +269 -0
- package/dist/cli-tickets.js +131 -0
- package/dist/cli.js +77 -0
- package/dist/daemon-lifecycle.js +372 -0
- package/dist/daemon.js +805 -0
- package/dist/daemonviews.js +691 -0
- package/dist/db.js +385 -0
- package/dist/docstore.js +110 -0
- package/dist/doctor.js +230 -0
- package/dist/init-service.js +206 -0
- package/dist/labelstore.js +34 -0
- package/dist/linear.js +60 -0
- package/dist/mcp-merge.js +145 -0
- package/dist/mirrorstore.js +128 -0
- package/dist/release-version.js +39 -0
- package/dist/resolve-project.js +82 -0
- package/dist/seed.js +76 -0
- package/dist/server.js +134 -0
- package/dist/shim.js +146 -0
- package/dist/ticketwrite.js +147 -0
- package/dist/tooldefs.js +147 -0
- package/dist/topicstore.js +174 -0
- package/package.json +91 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
// dev-loop hub daemon — the HTML web-UI view layer (DL-74 extraction from daemon.ts).
|
|
2
|
+
//
|
|
3
|
+
// Pure, read-only server-side rendering: every exported function RETURNS an HTML string (the
|
|
4
|
+
// routing/response layer in daemon.ts owns res via htmlOut/json). The board + ticket + roadmap +
|
|
5
|
+
// reports + activity pages render from the same read-only db connection the JSON API uses
|
|
6
|
+
// (PRAGMA query_only=ON) — no client JS, no bundler, no native deps. Every interpolated DB value
|
|
7
|
+
// passes through esc() (localhost-only + read-only, but the SoR holds arbitrary agent-authored
|
|
8
|
+
// text, so we escape it rather than trust it). No write path, no network, no res handling lives here.
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve, sep } from "node:path";
|
|
13
|
+
import { STATES } from "./db.js";
|
|
14
|
+
// ticket row → API shape (mirrors the MCP server's toTicket; labels/related_to are JSON columns).
|
|
15
|
+
// Shared by the HTML views below and the daemon.ts JSON API routes (a row-shape helper, not view-only).
|
|
16
|
+
export function toTicket(r) {
|
|
17
|
+
return {
|
|
18
|
+
id: r.id, title: r.title, description: r.description, type: r.type, state: r.state,
|
|
19
|
+
assignee: r.assignee, priority: r.priority,
|
|
20
|
+
labels: JSON.parse(r.labels), duplicateOf: r.duplicate_of, relatedTo: JSON.parse(r.related_to),
|
|
21
|
+
created_by: r.created_by, created_at: r.created_at, updated_at: r.updated_at,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const PRIORITY = { 1: "Urgent", 2: "High", 3: "Medium", 4: "Low", 0: "None" };
|
|
25
|
+
const CORE_STATES = ["Todo", "In Progress", "In Review", "Done"]; // always shown (Linear-like board)
|
|
26
|
+
// Human-Blocked (DL-25) is a parking state — ordered after In Review, but rendered ONLY when populated
|
|
27
|
+
// (like Backlog/Canceled/Duplicate), so an empty Human-Blocked column never clutters a healthy board.
|
|
28
|
+
const STATE_ORDER = ["Backlog", "Todo", "In Progress", "In Review", "Human-Blocked", "Done", "Canceled", "Duplicate"];
|
|
29
|
+
const TERMINAL_STATES = ["Done", "Canceled", "Duplicate"]; // DL-45: excluded from the composition summary band (the band shows the shape of OPEN work)
|
|
30
|
+
const ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
31
|
+
export function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ESC[c]); }
|
|
32
|
+
function ownerOf(labels) { return labels.includes("pm") ? "pm" : labels.includes("qa") ? "qa" : "—"; }
|
|
33
|
+
function prioOf(p) { return PRIORITY[p] ?? String(p); }
|
|
34
|
+
const STYLE = `
|
|
35
|
+
:root{color-scheme:light dark;--bg:#f6f7f9;--card:#fff;--line:#e2e5ea;--ink:#1c1e21;--mut:#6b7280}
|
|
36
|
+
@media(prefers-color-scheme:dark){:root{--bg:#15171a;--card:#1e2126;--line:#2c3036;--ink:#e6e8eb;--mut:#9aa3af}}
|
|
37
|
+
*{box-sizing:border-box}body{margin:0;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink)}
|
|
38
|
+
header{display:flex;align-items:baseline;gap:.6rem;padding:.7rem 1rem;border-bottom:1px solid var(--line)}
|
|
39
|
+
header .home{font-weight:700;text-decoration:none;color:var(--ink)}header .proj{color:var(--mut)}
|
|
40
|
+
main{padding:1rem}
|
|
41
|
+
.board{display:flex;gap:.8rem;align-items:flex-start;overflow-x:auto}
|
|
42
|
+
.col{flex:0 0 260px;background:transparent}
|
|
43
|
+
.col h2{font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--mut);margin:.2rem .2rem .5rem;font-weight:600}
|
|
44
|
+
.col .count,.lane-h .count{background:var(--line);color:var(--mut);border-radius:999px;padding:0 .45rem;margin-left:.3rem;font-size:.72rem}
|
|
45
|
+
.swimlanes{display:flex;flex-direction:column;gap:1.1rem}
|
|
46
|
+
.lane{border-top:1px solid var(--line);padding-top:.55rem}
|
|
47
|
+
.lane-h{font-size:.85rem;font-weight:600;margin:.1rem .2rem .55rem;color:var(--ink)}
|
|
48
|
+
.who{color:var(--ink);font-weight:500}
|
|
49
|
+
.group-tg{display:inline-flex;align-items:center;gap:.25rem;margin-left:.2rem;font-size:.72rem;color:var(--mut)}
|
|
50
|
+
.lbl.on{color:var(--ink);border-color:var(--mut);background:var(--card)}
|
|
51
|
+
.card{display:block;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:.55rem .6rem;margin-bottom:.5rem;text-decoration:none;color:inherit}
|
|
52
|
+
.card:hover{border-color:var(--mut)}
|
|
53
|
+
.card-top{display:flex;align-items:center;gap:.4rem;margin-bottom:.3rem}
|
|
54
|
+
.id{font:600 .72rem ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--mut)}
|
|
55
|
+
.title{font-weight:500;margin:.1rem 0}
|
|
56
|
+
.card-meta{display:flex;gap:.5rem;align-items:center;margin-top:.35rem;font-size:.74rem;color:var(--mut)}
|
|
57
|
+
.badge{font-size:.68rem;border:1px solid var(--line);border-radius:4px;padding:0 .35rem;color:var(--mut)}
|
|
58
|
+
.badge.t-Feature{color:#2563eb;border-color:#2563eb55}.badge.t-Bug{color:#dc2626;border-color:#dc262655}.badge.t-Improvement{color:#16a34a;border-color:#16a34a55}
|
|
59
|
+
.prio.p1{color:#dc2626;font-weight:600}.prio.p2{color:#d97706}
|
|
60
|
+
.empty{color:var(--mut);font-size:.8rem;padding:.3rem .2rem}
|
|
61
|
+
.back{display:inline-block;margin-bottom:.8rem;color:var(--mut);text-decoration:none}.back:hover{color:var(--ink)}
|
|
62
|
+
.detail{max-width:760px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.1rem 1.3rem}
|
|
63
|
+
.detail h1{font-size:1.4rem;margin:.4rem 0 .8rem}
|
|
64
|
+
.meta{display:grid;grid-template-columns:max-content 1fr;gap:.25rem .8rem;margin:.6rem 0 1rem}
|
|
65
|
+
.meta dt{color:var(--mut)}.meta dd{margin:0}
|
|
66
|
+
.lbl{font-size:.7rem;border:1px solid var(--line);border-radius:4px;padding:0 .35rem;color:var(--mut);margin-right:.25rem}
|
|
67
|
+
pre{white-space:pre-wrap;word-wrap:break-word;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:.7rem .8rem;font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;overflow-x:auto}
|
|
68
|
+
h3{margin:1.2rem 0 .4rem;font-size:.95rem}
|
|
69
|
+
.comment{margin:.5rem 0}.c-head{font-size:.78rem;color:var(--mut);margin-bottom:.2rem}.c-head time{margin-left:.4rem}
|
|
70
|
+
nav{margin-left:auto}nav a{color:var(--mut);text-decoration:none}nav a:hover{color:var(--ink)}
|
|
71
|
+
form{margin:.7rem 0}form label{display:block;margin:.45rem 0;color:var(--mut);font-size:.82rem}
|
|
72
|
+
textarea{display:block;width:100%;margin:.3rem 0;padding:.6rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
73
|
+
input[type=text]{padding:.3rem .45rem;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink);font:inherit}
|
|
74
|
+
button{font:inherit;padding:.4rem .85rem;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink);cursor:pointer}button:hover{border-color:var(--mut)}
|
|
75
|
+
.pub{margin-top:.5rem}
|
|
76
|
+
.notice{padding:.5rem .7rem;border-radius:8px;margin:.6rem 0;font-size:.85rem}
|
|
77
|
+
.n-err{background:#dc26261f;border:1px solid #dc262655;color:#dc2626}.n-ok{background:#16a34a1f;border:1px solid #16a34a55;color:#16a34a}.n-info{background:#64748b1f;border:1px solid #64748b55;color:#475569}
|
|
78
|
+
.doc h1,.doc h2,.doc h3{margin:.7rem 0 .3rem;font-size:1rem}.doc ul,.doc ol{margin:.3rem 0;padding-left:1.3rem}.doc p{margin:.4rem 0}.doc hr{border:0;border-top:1px solid var(--line);margin:.7rem 0}
|
|
79
|
+
code{font:.92em ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);padding:0 .25rem;border-radius:4px}
|
|
80
|
+
.ragent{margin:.9rem 0}.ragent h3{margin:.2rem 0 .4rem}
|
|
81
|
+
.rlevel{display:flex;gap:.4rem;align-items:baseline;flex-wrap:wrap;margin:.25rem 0}
|
|
82
|
+
.rkey{font-size:.68rem;text-transform:uppercase;letter-spacing:.03em;color:var(--mut);min-width:3.5rem}
|
|
83
|
+
.warn{color:#dc2626;font-weight:600}.sub{color:var(--mut)}
|
|
84
|
+
.lbl{cursor:pointer}a.lbl:hover{border-color:var(--mut);color:var(--ink)}
|
|
85
|
+
.filterbar{display:flex;gap:.45rem;align-items:center;flex-wrap:wrap;margin:0 0 .8rem}
|
|
86
|
+
.filterbar .chips{display:flex;gap:.3rem;flex-wrap:wrap;margin-left:.2rem}
|
|
87
|
+
.filterbar .clearall{border-style:dashed}
|
|
88
|
+
.summary{display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin:0 0 .8rem;padding:.4rem .55rem;background:var(--card);border:1px solid var(--line);border-radius:8px}
|
|
89
|
+
.summary .sum-grp{display:flex;gap:.3rem;flex-wrap:wrap}
|
|
90
|
+
.summary .lbl{cursor:default}.summary .lbl b{color:var(--ink);font-weight:600}
|
|
91
|
+
`;
|
|
92
|
+
export function page(title, project, inner) {
|
|
93
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">`
|
|
94
|
+
+ `<meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>`
|
|
95
|
+
+ `<style>${STYLE}</style></head><body>`
|
|
96
|
+
+ `<header><a class="home" href="/">dev-loop</a><span class="proj">${esc(project)}</span><nav><a href="/roadmap">roadmap</a> · <a href="/activity">activity</a> · <a href="/reports">reports</a></nav></header>`
|
|
97
|
+
+ `<main>${inner}</main></body></html>`;
|
|
98
|
+
}
|
|
99
|
+
function cardHtml(t) {
|
|
100
|
+
return `<a class="card" href="/ticket/${encodeURIComponent(t.id)}">`
|
|
101
|
+
+ `<div class="card-top"><span class="id">${esc(t.id)}</span><span class="badge t-${esc(t.type)}">${esc(t.type)}</span></div>`
|
|
102
|
+
+ `<div class="title">${esc(t.title)}</div>`
|
|
103
|
+
+ `<div class="card-meta"><span class="owner">${esc(ownerOf(t.labels))}</span>`
|
|
104
|
+
// DL-31: assignee chip — gated (rendered only when assigned), so unassigned cards stay clean. The
|
|
105
|
+
// operator reported the board never showed who a ticket is assigned to; this surfaces it on the card.
|
|
106
|
+
+ (t.assignee ? `<span class="who">@${esc(t.assignee)}</span>` : "")
|
|
107
|
+
+ `<span class="prio p${esc(t.priority)}">${esc(prioOf(t.priority))}</span></div></a>`;
|
|
108
|
+
}
|
|
109
|
+
const FILTER_KEYS = ["state", "type", "label", "assignee", "q"];
|
|
110
|
+
// Board: tickets grouped into state columns. Core workflow columns always render (even empty);
|
|
111
|
+
// Backlog/Canceled/Duplicate and any other state show only when populated, terminals last. DL-20 adds
|
|
112
|
+
// optional server-side filter/search (from the GET / query string) + a clearable, deep-linkable control row.
|
|
113
|
+
// DL-86: `opts` lets a failed create (POST /ticket) RE-RENDER the board with an inline error notice (instead
|
|
114
|
+
// of a raw-JSON dead-end) and preserve the operator's typed title in the create form (DL-14-style).
|
|
115
|
+
export function boardPage(db, projectId, projectKey, filters = {}, canWrite = false, group, opts = {}) {
|
|
116
|
+
let tickets = db.prepare("SELECT * FROM tickets WHERE project_id=? ORDER BY priority ASC, updated_at DESC").all(projectId).map(toTicket);
|
|
117
|
+
const f = filters;
|
|
118
|
+
// mirror /api/tickets: each present (non-empty) filter narrows the set; q matches id/title, case-insensitive
|
|
119
|
+
if (f.state)
|
|
120
|
+
tickets = tickets.filter((t) => t.state === f.state);
|
|
121
|
+
if (f.type)
|
|
122
|
+
tickets = tickets.filter((t) => t.type === f.type);
|
|
123
|
+
if (f.label)
|
|
124
|
+
tickets = tickets.filter((t) => t.labels.includes(f.label));
|
|
125
|
+
if (f.assignee)
|
|
126
|
+
tickets = tickets.filter((t) => t.assignee === f.assignee);
|
|
127
|
+
if (f.q) {
|
|
128
|
+
const q = f.q.toLowerCase();
|
|
129
|
+
tickets = tickets.filter((t) => String(t.id).toLowerCase().includes(q) || String(t.title ?? "").toLowerCase().includes(q));
|
|
130
|
+
}
|
|
131
|
+
// DL-31: ?group=assignee (validated upstream to the one known value) switches the board to assignee
|
|
132
|
+
// swimlanes. swim===false is byte-identical to the pre-DL-31 board apart from the always-present group
|
|
133
|
+
// toggle. The URL helper carries `group` so filter/search/chip links keep the active view (deep-linkable).
|
|
134
|
+
const swim = group === "assignee";
|
|
135
|
+
const qstr = (over = {}) => {
|
|
136
|
+
const p = new URLSearchParams();
|
|
137
|
+
for (const k of FILTER_KEYS)
|
|
138
|
+
if (f[k] && k !== over.omit)
|
|
139
|
+
p.set(k, f[k]);
|
|
140
|
+
const g = over.group === undefined ? group : over.group; // null ⇒ explicitly drop group
|
|
141
|
+
if (g)
|
|
142
|
+
p.set("group", g);
|
|
143
|
+
const s = p.toString();
|
|
144
|
+
return s ? `/?${s}` : "/";
|
|
145
|
+
};
|
|
146
|
+
// control row: active filters as clearable chips + a free-text search form + a state↔assignee group
|
|
147
|
+
// toggle; all reflected in the URL. A chip's link drops just that key but keeps the group view;
|
|
148
|
+
// "clear all" drops every filter but keeps the view. esc() everything (AC4).
|
|
149
|
+
const active = FILTER_KEYS.filter((k) => f[k]);
|
|
150
|
+
const chips = active.map((k) => `<a class="lbl" href="${esc(qstr({ omit: k }))}">${esc(k)}: ${esc(f[k])} ✕</a>`).join(" ");
|
|
151
|
+
const hidden = ["state", "type", "label", "assignee"].map((k) => f[k] ? `<input type="hidden" name="${k}" value="${esc(f[k])}">` : "").join("")
|
|
152
|
+
+ (group ? `<input type="hidden" name="group" value="${esc(group)}">` : "");
|
|
153
|
+
const groupToggle = `<span class="group-tg">group:`
|
|
154
|
+
+ `<a class="lbl${swim ? "" : " on"}" href="${esc(qstr({ group: null }))}">state</a>`
|
|
155
|
+
+ `<a class="lbl${swim ? " on" : ""}" href="${esc(qstr({ group: "assignee" }))}">assignee</a></span>`;
|
|
156
|
+
const controls = `<form class="filterbar" method="get" action="/">${hidden}`
|
|
157
|
+
+ `<input type="text" name="q" value="${esc(f.q ?? "")}" placeholder="search id / title" spellcheck="false">`
|
|
158
|
+
+ `<button type="submit">search</button>`
|
|
159
|
+
+ (active.length ? `<a class="lbl clearall" href="${esc(swim ? "/?group=assignee" : "/")}">clear all</a>` : "")
|
|
160
|
+
+ groupToggle
|
|
161
|
+
+ (chips ? `<span class="chips">${chips}</span>` : "")
|
|
162
|
+
+ `</form>`;
|
|
163
|
+
// Column ordering computed ONCE over the full filtered set so every swimlane shares an aligned column
|
|
164
|
+
// layout (CORE_STATES always render; populated extras appended, non-STATE_ORDER states last).
|
|
165
|
+
const allByState = new Map();
|
|
166
|
+
for (const t of tickets)
|
|
167
|
+
(allByState.get(t.state) ?? allByState.set(t.state, []).get(t.state)).push(t);
|
|
168
|
+
const states = [
|
|
169
|
+
...STATE_ORDER.filter((s) => CORE_STATES.includes(s) || allByState.has(s)),
|
|
170
|
+
...[...allByState.keys()].filter((s) => !STATE_ORDER.includes(s)),
|
|
171
|
+
];
|
|
172
|
+
const columnsFor = (subset) => {
|
|
173
|
+
const byState = new Map();
|
|
174
|
+
for (const t of subset)
|
|
175
|
+
(byState.get(t.state) ?? byState.set(t.state, []).get(t.state)).push(t);
|
|
176
|
+
const cols = states.map((s) => {
|
|
177
|
+
const cards = byState.get(s) ?? [];
|
|
178
|
+
const body = cards.length ? cards.map(cardHtml).join("") : `<p class="empty">—</p>`;
|
|
179
|
+
return `<section class="col"><h2>${esc(s)}<span class="count">${cards.length}</span></h2>${body}</section>`;
|
|
180
|
+
}).join("");
|
|
181
|
+
return `<div class="board">${cols}</div>`;
|
|
182
|
+
};
|
|
183
|
+
let boardHtml;
|
|
184
|
+
if (swim) {
|
|
185
|
+
// one lane per distinct assignee (sorted), with the unassigned lane last; each lane reuses the shared
|
|
186
|
+
// aligned columns. Assignee labels esc()'d (operator-controlled DATA → never trusted as markup).
|
|
187
|
+
const named = [...new Set(tickets.map((t) => t.assignee).filter((a) => !!a))].sort();
|
|
188
|
+
const lanesKeys = [...named, ...(tickets.some((t) => !t.assignee) ? [null] : [])];
|
|
189
|
+
boardHtml = `<div class="swimlanes">` + lanesKeys.map((a) => {
|
|
190
|
+
const subset = tickets.filter((t) => (a === null ? !t.assignee : t.assignee === a));
|
|
191
|
+
const label = a === null ? "unassigned" : `@${a}`;
|
|
192
|
+
return `<section class="lane"><h2 class="lane-h">${esc(label)}<span class="count">${subset.length}</span></h2>${columnsFor(subset)}</section>`;
|
|
193
|
+
}).join("") + `</div>`;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
boardHtml = columnsFor(tickets);
|
|
197
|
+
}
|
|
198
|
+
// empty state (AC3): when nothing matches, show the existing empty element — filter-aware so it reads
|
|
199
|
+
// accurately ("none match" when filtering vs. "none yet" on a genuinely empty board).
|
|
200
|
+
const empty = tickets.length === 0
|
|
201
|
+
? (active.length ? `<p class="empty">No tickets match the active filters.</p>` : `<p class="empty">No tickets in ${esc(projectKey)} yet.</p>`)
|
|
202
|
+
: "";
|
|
203
|
+
// DL-29: opt-in "new ticket" form (only when humanWrite is enabled — gated upstream). POST → the daemon
|
|
204
|
+
// create route, then PRG to the new ticket. esc() the option values (our own constants, but uniform).
|
|
205
|
+
const newForm = canWrite
|
|
206
|
+
? `<form class="newticket" method="post" action="/ticket">`
|
|
207
|
+
+ `<input type="text" name="title" value="${esc(opts.submittedTitle ?? "")}" placeholder="New ticket title" required spellcheck="false">` // DL-86: preserve typed title on a rejected create
|
|
208
|
+
+ `<select name="type"><option>Feature</option><option>Bug</option><option>Improvement</option></select>`
|
|
209
|
+
+ `<button type="submit">+ New ticket</button></form>`
|
|
210
|
+
: "";
|
|
211
|
+
// DL-45: an at-a-glance composition summary band over the NON-TERMINAL tickets of the (filtered) set — by
|
|
212
|
+
// type, owner, and priority. A pure read-only aggregate over the rows already fetched + filtered above, so it
|
|
213
|
+
// always agrees with the columns below it (and with the swimlanes, which split this same `tickets` set). The
|
|
214
|
+
// terminal states (Done/Canceled/Duplicate) are excluded — the band shows the shape of OPEN work. Hidden when
|
|
215
|
+
// there is no open work (an empty / all-terminal set) so it never renders an all-zero strip.
|
|
216
|
+
const open = tickets.filter((t) => !TERMINAL_STATES.includes(t.state));
|
|
217
|
+
const sumChip = (label, n) => `<span class="lbl">${esc(label)} <b>${n}</b></span>`;
|
|
218
|
+
const sumGrp = (chips) => `<span class="sum-grp">${chips}</span>`;
|
|
219
|
+
const summary = open.length
|
|
220
|
+
? `<div class="summary" title="composition of the ${open.length} open (non-terminal) ticket(s)${active.length ? ", filtered" : ""}">`
|
|
221
|
+
+ sumGrp(["Feature", "Bug", "Improvement"].map((ty) => sumChip(ty, open.filter((t) => t.type === ty).length)).join(""))
|
|
222
|
+
+ sumGrp(["pm", "qa"].map((o) => sumChip(o, open.filter((t) => ownerOf(t.labels) === o).length)).join(""))
|
|
223
|
+
+ sumGrp([1, 2, 3, 4, 0].map((p) => sumChip(prioOf(p), open.filter((t) => t.priority === p).length)).join(""))
|
|
224
|
+
+ `</div>`
|
|
225
|
+
: "";
|
|
226
|
+
// DL-86: an inline error notice on a failed create, rendered above the create form (mirrors roadmapPage's notice).
|
|
227
|
+
const notice = opts.notice ? `<p class="notice ${opts.notice.kind === "error" ? "n-err" : "n-ok"}">${esc(opts.notice.msg)}</p>` : "";
|
|
228
|
+
return notice + controls + newForm + summary + boardHtml + empty;
|
|
229
|
+
}
|
|
230
|
+
// Ticket detail: full description + comments. Returns null when the ticket is absent (→ 404).
|
|
231
|
+
// DL-86: `opts` lets a failed human-write (move/assign/comment) RE-RENDER this page with an inline error
|
|
232
|
+
// notice (instead of a raw-JSON dead-end) and preserve the operator's typed comment in the textarea (DL-14-style).
|
|
233
|
+
export function ticketPage(db, projectId, id, canWrite = false, opts = {}) {
|
|
234
|
+
const r = db.prepare("SELECT * FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
|
|
235
|
+
if (!r)
|
|
236
|
+
return null;
|
|
237
|
+
const t = toTicket(r);
|
|
238
|
+
const comments = db.prepare("SELECT author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(id);
|
|
239
|
+
const commentsHtml = comments.length
|
|
240
|
+
? comments.map((c) => `<div class="comment"><div class="c-head"><b>${esc(c.author)}</b><time>${esc(c.created_at)}</time></div><div class="doc">${renderMarkdown(c.body)}</div></div>`).join("")
|
|
241
|
+
: `<p class="empty">No comments yet.</p>`;
|
|
242
|
+
// DL-8: surface the hub relationships (relatedTo / duplicateOf) as click-through links — but ONLY
|
|
243
|
+
// when present, so an unrelated ticket renders no dangling row (AC). Read-only GET navigation.
|
|
244
|
+
const relLink = (rid) => `<a class="lbl" href="/ticket/${encodeURIComponent(rid)}">${esc(rid)}</a>`;
|
|
245
|
+
const relatedRow = t.relatedTo?.length ? `<dt>Related</dt><dd>${t.relatedTo.map(relLink).join(" ")}</dd>` : "";
|
|
246
|
+
const dupRow = t.duplicateOf ? `<dt>Duplicate of</dt><dd>${relLink(t.duplicateOf)}</dd>` : "";
|
|
247
|
+
return `<a class="back" href="/">← board</a><article class="detail">`
|
|
248
|
+
+ `<div class="card-top"><span class="id">${esc(t.id)}</span><span class="badge t-${esc(t.type)}">${esc(t.type)}</span><span class="badge">${esc(t.state)}</span></div>`
|
|
249
|
+
+ `<h1>${esc(t.title)}</h1>`
|
|
250
|
+
+ (opts.notice ? `<p class="notice ${opts.notice.kind === "error" ? "n-err" : "n-ok"}">${esc(opts.notice.msg)}</p>` : "") // DL-86: inline error on a failed write
|
|
251
|
+
+ `<dl class="meta"><dt>Owner</dt><dd>${esc(ownerOf(t.labels))}</dd>`
|
|
252
|
+
+ `<dt>Priority</dt><dd>${esc(prioOf(t.priority))}</dd>`
|
|
253
|
+
+ `<dt>Assignee</dt><dd>${esc(t.assignee ?? "—")}</dd>`
|
|
254
|
+
+ `<dt>Created</dt><dd>${esc(t.created_at)}</dd><dt>Updated</dt><dd>${esc(t.updated_at)}</dd>` // DL-16
|
|
255
|
+
+ `<dt>Labels</dt><dd>${t.labels.map((l) => `<span class="lbl">${esc(l)}</span>`).join("")}</dd>${relatedRow}${dupRow}</dl>`
|
|
256
|
+
+ `<h3>Description</h3><div class="doc">${renderMarkdown(t.description)}</div>` // DL-16: rendered markdown (XSS-safe via renderMarkdown), not raw <pre>
|
|
257
|
+
+ `<h3>Comments<span class="count" style="margin-left:.4rem">${comments.length}</span></h3>${commentsHtml}`
|
|
258
|
+
// DL-29: opt-in human actions (only when humanWrite is enabled — gated upstream). Each POSTs to a
|
|
259
|
+
// daemon write route then PRG-redirects back here. All interpolated values esc()'d; comment/assignee
|
|
260
|
+
// are operator DATA (stored verbatim, never parsed). Move offers the STATES set, current pre-selected.
|
|
261
|
+
+ (canWrite
|
|
262
|
+
? `<h3>Actions</h3>`
|
|
263
|
+
+ `<form class="act" method="post" action="/ticket/${encodeURIComponent(id)}/comment"><textarea name="body" rows="3" placeholder="Add a comment" required spellcheck="false">${esc(opts.submittedComment ?? "")}</textarea><button type="submit">Comment</button></form>` // DL-86: preserve typed text on a rejected comment (DL-14-style)
|
|
264
|
+
+ `<form class="act" method="post" action="/ticket/${encodeURIComponent(id)}/move"><select name="state">${STATES.map((s) => `<option${s === t.state ? " selected" : ""}>${esc(s)}</option>`).join("")}</select><button type="submit">Move</button></form>`
|
|
265
|
+
+ `<form class="act" method="post" action="/ticket/${encodeURIComponent(id)}/assign"><input type="text" name="assignee" value="${esc(t.assignee ?? "")}" placeholder="assignee handle (blank = unassign)" spellcheck="false"><button type="submit">Assign</button></form>`
|
|
266
|
+
: "")
|
|
267
|
+
+ `</article>`;
|
|
268
|
+
}
|
|
269
|
+
// ─── DL-3: roadmap view ─────────────────────────────────────────────────────────
|
|
270
|
+
// A tiny, dependency-free, XSS-safe markdown renderer for the roadmap view. The body is arbitrary
|
|
271
|
+
// agent-authored text, so we esc() FIRST (no user content can then inject a tag), and only THEN apply a
|
|
272
|
+
// closed set of block/inline transforms that emit ONLY our own <h*>/<ul>/<ol>/<li>/<strong>/<code>/<hr>/<p>.
|
|
273
|
+
function renderMarkdown(md) {
|
|
274
|
+
const inline = (s) => s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
275
|
+
const out = [];
|
|
276
|
+
let listTag = null;
|
|
277
|
+
const closeList = () => { if (listTag) {
|
|
278
|
+
out.push(`</${listTag}>`);
|
|
279
|
+
listTag = null;
|
|
280
|
+
} };
|
|
281
|
+
for (const raw of esc(md).split("\n")) {
|
|
282
|
+
const line = raw.trimEnd();
|
|
283
|
+
let m;
|
|
284
|
+
if (/^\s*$/.test(line)) {
|
|
285
|
+
closeList();
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
|
|
289
|
+
closeList();
|
|
290
|
+
const l = m[1].length;
|
|
291
|
+
out.push(`<h${l}>${inline(m[2])}</h${l}>`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
295
|
+
closeList();
|
|
296
|
+
out.push("<hr>");
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
|
|
300
|
+
if (listTag !== "ul") {
|
|
301
|
+
closeList();
|
|
302
|
+
out.push("<ul>");
|
|
303
|
+
listTag = "ul";
|
|
304
|
+
}
|
|
305
|
+
const cb = m[1].match(/^\[([ xX])\]\s+([\s\S]*)$/);
|
|
306
|
+
out.push(cb ? `<li><input type="checkbox" disabled${cb[1] === " " ? "" : " checked"}> ${inline(cb[2])}</li>` : `<li>${inline(m[1])}</li>`);
|
|
307
|
+
continue;
|
|
308
|
+
} // DL-16: a `- [ ]`/`- [x]` item → a disabled checkbox (the text is already esc'd → XSS-safe)
|
|
309
|
+
if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
|
|
310
|
+
if (listTag !== "ol") {
|
|
311
|
+
closeList();
|
|
312
|
+
out.push("<ol>");
|
|
313
|
+
listTag = "ol";
|
|
314
|
+
}
|
|
315
|
+
out.push(`<li>${inline(m[1])}</li>`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
closeList();
|
|
319
|
+
out.push(`<p>${inline(line)}</p>`);
|
|
320
|
+
}
|
|
321
|
+
closeList();
|
|
322
|
+
return out.join("\n");
|
|
323
|
+
}
|
|
324
|
+
// GET /roadmap — render the kind:"roadmap" document (rendered markdown) + version/status, plus the edit
|
|
325
|
+
// form and (operator-only) publish control. Reads through the query_only `db`. slug/kind are NEVER form
|
|
326
|
+
// fields: the write routes hard-target the roadmap doc, so caller input can't redirect the write (§17).
|
|
327
|
+
export function roadmapPage(db, projectId, opts) {
|
|
328
|
+
const d = db.prepare("SELECT * FROM documents WHERE project_id=? AND kind='roadmap'").get(projectId);
|
|
329
|
+
const latest = d ? (db.prepare("SELECT max(version) v FROM document_versions WHERE doc_id=?").get(d.id).v ?? 0) : 0;
|
|
330
|
+
const published = d ? d.current_version : 0;
|
|
331
|
+
const showVer = latest; // view + edit the LATEST version (draft or published) so the edit loop builds on the newest
|
|
332
|
+
const cur = (d && showVer > 0) ? db.prepare("SELECT version,body,status FROM document_versions WHERE doc_id=? AND version=?").get(d.id, showVer) : undefined;
|
|
333
|
+
const body = cur?.body ?? "";
|
|
334
|
+
const notice = opts.notice ? `<p class="notice ${opts.notice.kind === "error" ? "n-err" : "n-ok"}">${esc(opts.notice.msg)}</p>` : "";
|
|
335
|
+
// DL-83: when the hub roadmap doc is NOT this project's north-star (a repo-file strategyDoc is, because no
|
|
336
|
+
// agent reads the hub roadmap under hub.docs:false/absent + no director), a NEUTRAL informational banner
|
|
337
|
+
// sets expectations — editing here won't steer the loop. Purely informational: it never hides the
|
|
338
|
+
// view/edit/publish controls (AC2). The path comes from the daemon's resolved config (§17) and is esc'd.
|
|
339
|
+
const divergence = opts.roadmapRepoFileStrategy
|
|
340
|
+
? `<p class="notice n-info">This project's north-star is the repo file <code>${esc(opts.roadmapRepoFileStrategy)}</code> — this hub roadmap is <b>not read by the agents</b> under the current config (no <code>hub.docs</code>, no <code>director</code>), so edits here won't steer the loop.</p>`
|
|
341
|
+
: "";
|
|
342
|
+
const meta = d
|
|
343
|
+
? `<dl class="meta"><dt>Status</dt><dd>${esc(d.status)}</dd>`
|
|
344
|
+
+ `<dt>Latest version</dt><dd>v${latest}${latest > 0 ? ` (${esc(cur?.status ?? "draft")})` : ""}</dd>`
|
|
345
|
+
+ `<dt>Published</dt><dd>${published > 0 ? `v${published}` : "none — draft only"}</dd></dl>`
|
|
346
|
+
: `<p class="empty">No roadmap document yet — saving below creates the first draft.</p>`;
|
|
347
|
+
const view = `<h3>${latest > 0 ? (latest === published ? `Published (v${latest})` : `Draft (v${latest}, unpublished)`) : "Roadmap"}</h3>`
|
|
348
|
+
+ (body ? `<div class="doc">${renderMarkdown(body)}</div>` : `<p class="empty">(empty)</p>`);
|
|
349
|
+
let controls = "";
|
|
350
|
+
if (opts.writable) {
|
|
351
|
+
controls = `<h3>Edit — saves a DRAFT (never publishes)</h3>`
|
|
352
|
+
+ `<form method="post" action="/roadmap/save">`
|
|
353
|
+
+ `<input type="hidden" name="baseVersion" value="${latest}">` // server-derived CAS base; a stale base is rejected, not overwritten
|
|
354
|
+
+ `<textarea name="body" rows="16" spellcheck="false">${esc(opts.submittedBody ?? body)}</textarea>` // DL-14: on a rejected save, keep the user's typed text (?? — an empty submission stays empty), not the DB body
|
|
355
|
+
+ `<label>Summary (optional) <input type="text" name="summary" placeholder="what changed"></label>`
|
|
356
|
+
+ `<button type="submit">Save draft</button></form>`;
|
|
357
|
+
if (latest > 0) {
|
|
358
|
+
controls += opts.canPublish
|
|
359
|
+
? `<form method="post" action="/roadmap/publish" class="pub"><input type="hidden" name="version" value="${latest}">`
|
|
360
|
+
+ `<button type="submit">Publish v${latest} → current</button></form>`
|
|
361
|
+
: `<p class="empty">Publishing a draft → current is <b>operator-only</b>. This daemon runs as a non-operator actor, so the publish control is hidden (§16/§17).</p>`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
controls = `<p class="empty">This daemon is read-only — no write surface is configured.</p>`;
|
|
366
|
+
}
|
|
367
|
+
return `<a class="back" href="/">← board</a><article class="detail">`
|
|
368
|
+
+ `<div class="card-top"><span class="id">roadmap</span><span class="badge">${esc(d?.status ?? "—")}</span></div>`
|
|
369
|
+
+ `<h1>${esc(d?.title ?? "Roadmap")}</h1>` + divergence + notice + meta + view + controls + `</article>`;
|
|
370
|
+
}
|
|
371
|
+
// ── DL-10: agent reports view (read-only, FILESYSTEM source — separate from the hub DB) ──────────
|
|
372
|
+
// The §22 reports tree is machine-local markdown. Resolve its root: DEVLOOP_REPORTS_DIR if set, else the
|
|
373
|
+
// FIRST EXISTING of a few candidates (the on-disk layout varies — both <data>/<project>/reports and a
|
|
374
|
+
// flat <data>/reports exist in the wild); falls back to the AC-formula path for the empty state.
|
|
375
|
+
const REPORT_DATED = { daily: /^\d{4}-\d{2}-\d{2}$/, weekly: /^\d{4}-W\d{2}$/, monthly: /^\d{4}-\d{2}$/ };
|
|
376
|
+
export function reportsRoot(projectKey) {
|
|
377
|
+
if (process.env.DEVLOOP_REPORTS_DIR)
|
|
378
|
+
return process.env.DEVLOOP_REPORTS_DIR;
|
|
379
|
+
const bases = [process.env.CLAUDE_PLUGIN_DATA, join(homedir(), ".claude", "plugins", "data", "dev-loop")].filter(Boolean);
|
|
380
|
+
const candidates = bases.flatMap((b) => [join(b, projectKey, "reports"), join(b, "reports")]);
|
|
381
|
+
for (const c of candidates) {
|
|
382
|
+
try {
|
|
383
|
+
if (statSync(c).isDirectory())
|
|
384
|
+
return c;
|
|
385
|
+
}
|
|
386
|
+
catch { /* not here */ }
|
|
387
|
+
}
|
|
388
|
+
return candidates[0]; // AC-formula path; may not exist → empty state at read time
|
|
389
|
+
}
|
|
390
|
+
const lsSubdirs = (p) => { try {
|
|
391
|
+
return readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return [];
|
|
395
|
+
} };
|
|
396
|
+
// Only the §22 dated-report files for the level — this inherently EXCLUDES *.review.md / *.review.acted.
|
|
397
|
+
const lsDated = (p, level) => { const re = REPORT_DATED[level]; try {
|
|
398
|
+
return re ? readdirSync(p).filter((f) => f.endsWith(".md") && re.test(f.slice(0, -3))).sort().reverse() : [];
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return [];
|
|
402
|
+
} };
|
|
403
|
+
// GET /reports — agents + their dated reports (daily is the must-have; weekly/monthly when present).
|
|
404
|
+
export function reportsIndexPage(root) {
|
|
405
|
+
const agents = lsSubdirs(root).sort();
|
|
406
|
+
const sections = agents.map((agent) => {
|
|
407
|
+
const levels = ["daily", "weekly", "monthly"].map((level) => {
|
|
408
|
+
const files = lsDated(join(root, agent, level), level);
|
|
409
|
+
if (!files.length)
|
|
410
|
+
return "";
|
|
411
|
+
const items = files.map((f) => { const d = f.slice(0, -3); return `<a class="lbl" href="/reports/${encodeURIComponent(agent)}/${level}/${encodeURIComponent(d)}">${esc(d)}</a>`; }).join(" ");
|
|
412
|
+
return `<div class="rlevel"><span class="rkey">${esc(level)}</span>${items}</div>`;
|
|
413
|
+
}).filter(Boolean).join("");
|
|
414
|
+
return levels ? `<section class="ragent"><h3>${esc(agent)}</h3>${levels}</section>` : "";
|
|
415
|
+
}).filter(Boolean).join("");
|
|
416
|
+
return `<a class="back" href="/">← board</a><article class="detail"><h1>Reports</h1>`
|
|
417
|
+
+ (sections || `<p class="empty">No reports found yet under <code>${esc(root)}</code>.</p>`) + `</article>`;
|
|
418
|
+
}
|
|
419
|
+
// GET /reports/<agent>/<level>/<date> — one report, read-only. "badpath" → 400 (traversal/garbage), null → 404.
|
|
420
|
+
export function reportPage(root, agent, level, date) {
|
|
421
|
+
// strict segment validation defeats path traversal BEFORE any fs access: agent is a single safe name
|
|
422
|
+
// (no `.`/`/`/`..`), level is one of the three, date matches the §22 grammar for that level.
|
|
423
|
+
if (!/^[A-Za-z0-9_-]+$/.test(agent) || !(level in REPORT_DATED) || !REPORT_DATED[level].test(date))
|
|
424
|
+
return "badpath";
|
|
425
|
+
const file = resolve(root, agent, level, `${date}.md`);
|
|
426
|
+
if (!file.startsWith(resolve(root) + sep))
|
|
427
|
+
return "badpath"; // defense-in-depth: the resolved path must stay within root
|
|
428
|
+
let body;
|
|
429
|
+
try {
|
|
430
|
+
body = readFileSync(file, "utf8");
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return { html: `<a class="back" href="/reports">← reports</a> · <a class="back" href="/">board</a>`
|
|
436
|
+
+ `<article class="detail"><div class="card-top"><span class="id">${esc(agent)}</span><span class="badge">${esc(level)}</span></div>`
|
|
437
|
+
+ `<h1>${esc(date)}</h1><div class="doc">${renderMarkdown(body)}</div></article>` };
|
|
438
|
+
}
|
|
439
|
+
// ── DL-17: activity & throughput view over the events ledger (read-only) ─────────────────────────
|
|
440
|
+
// A human-facing read over the append-only `events` table (issue.create / issue.transition{from,to} /
|
|
441
|
+
// comment.add, written by the MCP server at server.ts). Pure GET through the query_only `db`: no write
|
|
442
|
+
// path, no new MCP tool call, no new table. Robust to a null ticket_id and to empty/malformed `data`
|
|
443
|
+
// JSON — a bad row is skipped (metrics) or shown plainly (feed), never breaking the page (AC5).
|
|
444
|
+
const DAY_MS = 86_400_000;
|
|
445
|
+
// Defensive JSON parse of an event's `data` blob — empty / malformed / non-object → {} instead of throwing.
|
|
446
|
+
// Shared by the activity view below and the daemon.ts no-progress detector (same done-count logic).
|
|
447
|
+
export function eventData(s) {
|
|
448
|
+
if (typeof s !== "string" || s === "")
|
|
449
|
+
return {};
|
|
450
|
+
try {
|
|
451
|
+
const v = JSON.parse(s);
|
|
452
|
+
return v && typeof v === "object" ? v : {};
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return {};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Human-readable elapsed (ms → "3d 4h" / "2h 5m" / "12m" / "<1m"); NaN/negative → "—".
|
|
459
|
+
function humanDur(ms) {
|
|
460
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
461
|
+
return "—";
|
|
462
|
+
const m = Math.floor(ms / 60000), h = Math.floor(m / 60), d = Math.floor(h / 24);
|
|
463
|
+
if (d > 0)
|
|
464
|
+
return `${d}d ${h % 24}h`;
|
|
465
|
+
if (h > 0)
|
|
466
|
+
return `${h}h ${m % 60}m`;
|
|
467
|
+
if (m > 0)
|
|
468
|
+
return `${m}m`;
|
|
469
|
+
return "<1m";
|
|
470
|
+
}
|
|
471
|
+
// DL-84 — the pipeline stages whose residence time the /activity "Time in stage" diagnostic reports.
|
|
472
|
+
const STAGES = ["Todo", "In Progress", "In Review"];
|
|
473
|
+
// DL-89 — Open-WIP aging thresholds: how long a ticket may sit in an active state before /activity flags it
|
|
474
|
+
// stale. In Review past this = the owner agent (PM/QA) isn't verifying finished work (verify-lag); In Progress
|
|
475
|
+
// past this = a claim that outlived its Dev fire (possible-orphan, beyond Sweep's no-artifact reclaim).
|
|
476
|
+
const WIP_VERIFY_LAG_MS = 2 * DAY_MS; // In Review (AC3: "> 2 days")
|
|
477
|
+
const WIP_ORPHAN_MS = 1 * DAY_MS; // In Progress (a Dev fire should ship within its own run)
|
|
478
|
+
// DL-89 — the active states /activity ages, one source for both the query and the render list (so they can't
|
|
479
|
+
// drift). Core states always render (— none if empty); park states render only when populated — the
|
|
480
|
+
// parking-state rule, mirroring boardPage's STATE_ORDER/CORE_STATES handling.
|
|
481
|
+
const WIP_CORE_STATES = ["In Progress", "In Review"]; // always shown
|
|
482
|
+
const WIP_PARK_STATES = ["Human-Blocked"]; // shown only when populated
|
|
483
|
+
// DL-84/DL-89 — one ticket's create+transition history, ASC by id; shared by the cycle-time + open-WIP loops.
|
|
484
|
+
const HIST_SQL = "SELECT kind,data,created_at FROM events WHERE project_id=? AND ticket_id=? AND (kind='issue.create' OR kind='issue.transition') ORDER BY id";
|
|
485
|
+
// Median of a numeric list (ms); empty → undefined (caller renders "—"). Avg of the two middles for even n.
|
|
486
|
+
function median(nums) {
|
|
487
|
+
if (!nums.length)
|
|
488
|
+
return undefined;
|
|
489
|
+
const s = [...nums].sort((a, b) => a - b), mid = s.length >> 1;
|
|
490
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
491
|
+
}
|
|
492
|
+
// DL-84 — per-stage residence time (ms) for ONE ticket, summed across ALL intervals it spent in each stage
|
|
493
|
+
// (a state may be re-entered — a verify-fail reopen). An interval is [transition INTO a state, the next
|
|
494
|
+
// transition OUT]; issue.create anchors the initial state (whose value is the first transition's `from`), and
|
|
495
|
+
// the trailing open interval (the final state, e.g. Done) is NOT counted. Graceful on incomplete history (no
|
|
496
|
+
// create anchor → the initial interval is dropped) and on malformed rows (eventData → {} → an undefined state
|
|
497
|
+
// bounds the prior interval by its timestamp, then attributes nothing). hist is the ticket's create+transition
|
|
498
|
+
// events ordered ASC by id.
|
|
499
|
+
function stageDurations(hist) {
|
|
500
|
+
const acc = {};
|
|
501
|
+
let createT, firstFrom;
|
|
502
|
+
const trans = [];
|
|
503
|
+
for (const e of hist) {
|
|
504
|
+
if (e.kind === "issue.create") {
|
|
505
|
+
if (createT === undefined) {
|
|
506
|
+
const t = Date.parse(e.created_at);
|
|
507
|
+
if (Number.isFinite(t))
|
|
508
|
+
createT = t;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else if (e.kind === "issue.transition") {
|
|
512
|
+
const d = eventData(e.data), t = Date.parse(e.created_at);
|
|
513
|
+
if (!Number.isFinite(t))
|
|
514
|
+
continue; // a row with no usable timestamp bounds nothing — skip
|
|
515
|
+
if (!trans.length)
|
|
516
|
+
firstFrom = d.from; // the initial state = what the first transition leaves
|
|
517
|
+
trans.push({ to: d.to, t });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
let prevT = createT, prevState = firstFrom;
|
|
521
|
+
for (const tr of trans) {
|
|
522
|
+
if (prevT !== undefined && typeof prevState === "string" && STAGES.includes(prevState)) {
|
|
523
|
+
const dur = tr.t - prevT;
|
|
524
|
+
if (dur > 0)
|
|
525
|
+
acc[prevState] = (acc[prevState] ?? 0) + dur;
|
|
526
|
+
}
|
|
527
|
+
prevState = tr.to;
|
|
528
|
+
prevT = tr.t; // the next interval opens at this transition
|
|
529
|
+
}
|
|
530
|
+
return acc; // prevState's trailing open interval (e.g. Done) is uncounted
|
|
531
|
+
}
|
|
532
|
+
// One feed line per event, formatted by kind; every interpolation passes through esc() (AC6). A null
|
|
533
|
+
// ticket_id renders no link (AC5); unknown kinds (issue.update / topic.*) fall through to a plain line.
|
|
534
|
+
function eventLine(e) {
|
|
535
|
+
const d = eventData(e.data);
|
|
536
|
+
const who = `<b>${esc(e.actor)}</b>`;
|
|
537
|
+
const tlink = e.ticket_id ? ` <a class="lbl" href="/ticket/${encodeURIComponent(e.ticket_id)}">${esc(e.ticket_id)}</a>` : "";
|
|
538
|
+
let what;
|
|
539
|
+
switch (e.kind) {
|
|
540
|
+
case "issue.create":
|
|
541
|
+
what = `created${tlink} <span class="badge">${esc(d.type ?? "?")}</span> ${esc(d.title ?? "")}`;
|
|
542
|
+
break;
|
|
543
|
+
case "issue.transition":
|
|
544
|
+
what = `moved${tlink} <span class="lbl">${esc(d.from ?? "?")}</span> → <span class="lbl">${esc(d.to ?? "?")}</span>`;
|
|
545
|
+
break;
|
|
546
|
+
case "issue.promote":
|
|
547
|
+
what = `promoted${tlink} <span class="lbl">${esc(d.from || "—")}</span> → <span class="lbl">${esc(d.to || "—")}</span>`;
|
|
548
|
+
break; // DL-32 env-label change
|
|
549
|
+
case "comment.add":
|
|
550
|
+
what = `commented on${tlink || " a ticket"}`;
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
what = `${esc(e.kind)}${tlink}`;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
return `<div class="rlevel"><span class="rkey">${esc(e.created_at)}</span><span>${who} ${what}</span></div>`;
|
|
557
|
+
}
|
|
558
|
+
// GET /activity — recent events + throughput (Done transitions), acceptance rate, per-actor counts, and
|
|
559
|
+
// cycle time, all read through the query_only `db`. `nowMs` is injected (the daemon passes Date.now()) so
|
|
560
|
+
// the helper is pure/testable. Windows: 7d + 30d for throughput + acceptance rate; 30d for per-actor +
|
|
561
|
+
// cycle-time recency.
|
|
562
|
+
export function activityPage(db, projectId, projectKey, nowMs) {
|
|
563
|
+
const since30 = new Date(nowMs - 30 * DAY_MS).toISOString();
|
|
564
|
+
const since7 = new Date(nowMs - 7 * DAY_MS).toISOString();
|
|
565
|
+
// Recent feed — newest-first, bounded (the three named kinds get rich formatting; others fall through).
|
|
566
|
+
const feed = db.prepare("SELECT ticket_id,actor,kind,data,created_at FROM events WHERE project_id=? ORDER BY id DESC LIMIT 100").all(projectId);
|
|
567
|
+
// Transitions in the last 30d → Done throughput + the set of recently-Done tickets for cycle time.
|
|
568
|
+
const trans = db.prepare("SELECT ticket_id,data,created_at FROM events WHERE project_id=? AND kind='issue.transition' AND created_at>=? ORDER BY id").all(projectId, since30);
|
|
569
|
+
let done7 = 0, done30 = 0, fail7 = 0, fail30 = 0; // fail* = verify-fail Cancels (the accept-rate denominator, DL-79)
|
|
570
|
+
const doneAt = new Map(); // ticket_id → latest Done-transition time (in window)
|
|
571
|
+
for (const e of trans) {
|
|
572
|
+
const d = eventData(e.data); // parsed once; empty/malformed → {} → matches neither branch, skipped (AC5)
|
|
573
|
+
const in7 = e.created_at >= since7;
|
|
574
|
+
if (d.to === "Done") {
|
|
575
|
+
done30++;
|
|
576
|
+
if (in7)
|
|
577
|
+
done7++;
|
|
578
|
+
if (e.ticket_id) {
|
|
579
|
+
const prev = doneAt.get(e.ticket_id);
|
|
580
|
+
if (!prev || e.created_at > prev)
|
|
581
|
+
doneAt.set(e.ticket_id, e.created_at);
|
|
582
|
+
} // null ticket_id → counted in throughput, no cycle row (AC5)
|
|
583
|
+
}
|
|
584
|
+
else if (d.from === "In Review" && d.to === "Canceled") { // §3 verify-fail close+follow-up always leaves THIS exact edge — an ordinary Cancel (Todo/Backlog→Canceled) is NOT counted (DL-79)
|
|
585
|
+
fail30++;
|
|
586
|
+
if (in7)
|
|
587
|
+
fail7++;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Per-actor activity over the same 30d window.
|
|
591
|
+
const actors = db.prepare("SELECT actor,count(*) n FROM events WHERE project_id=? AND created_at>=? GROUP BY actor ORDER BY n DESC, actor").all(projectId, since30);
|
|
592
|
+
// Cycle time per recently-Done ticket: elapsed from the ticket's create (else first Todo transition) to
|
|
593
|
+
// its Done transition. When that start anchor is missing (incomplete history), render a graceful fallback.
|
|
594
|
+
const stageLists = Object.fromEntries(STAGES.map((s) => [s, []])); // DL-84: per-stage residence, keyed by STAGES (single source) across the same recently-Done set
|
|
595
|
+
const cycle = [...doneAt.entries()].sort((a, b) => (a[1] < b[1] ? 1 : -1)).map(([tid, done]) => {
|
|
596
|
+
const hist = db.prepare(HIST_SQL).all(projectId, tid);
|
|
597
|
+
let start;
|
|
598
|
+
for (const e of hist)
|
|
599
|
+
if (e.kind === "issue.create") {
|
|
600
|
+
start = e.created_at;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
if (!start)
|
|
604
|
+
for (const e of hist)
|
|
605
|
+
if (eventData(e.data).to === "Todo") {
|
|
606
|
+
start = e.created_at;
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
const sd = stageDurations(hist); // DL-84: folded off the same hist — no extra query per ticket
|
|
610
|
+
for (const st of STAGES)
|
|
611
|
+
if (sd[st] !== undefined)
|
|
612
|
+
stageLists[st].push(sd[st]); // a ticket contributes only the stages it actually had (AC4)
|
|
613
|
+
return { tid, done, label: start ? humanDur(Date.parse(done) - Date.parse(start)) : "— (incomplete history)" };
|
|
614
|
+
});
|
|
615
|
+
const metricRow = (k, v) => `<div class="rlevel"><span class="rkey">${esc(k)}</span><span>${v}</span></div>`;
|
|
616
|
+
const throughput = `<h3>Throughput — transitions into Done</h3>`
|
|
617
|
+
+ metricRow("last 7d", `<b>${esc(done7)}</b>`) + metricRow("last 30d", `<b>${esc(done30)}</b>`);
|
|
618
|
+
// Acceptance rate = Done ÷ (Done + verify-fail Cancels): is the loop's output being accepted, or churning?
|
|
619
|
+
// Raw counts shown for audit; flagged below 50% (the loop is likely losing money). A zero-denominator window
|
|
620
|
+
// renders a neutral "no data" — never a fake 0% or a divide-by-zero (DL-79 ACs).
|
|
621
|
+
const acceptVal = (done, fail) => {
|
|
622
|
+
const total = done + fail;
|
|
623
|
+
if (total === 0)
|
|
624
|
+
return `<span class="sub">— no data</span>`;
|
|
625
|
+
const rate = Math.round((done / total) * 100);
|
|
626
|
+
const head = rate < 50 ? `<span class="warn">${esc(rate)}% ⚠ low</span>` : `<b>${esc(rate)}%</b>`;
|
|
627
|
+
return `${head} <span class="sub">Done ${esc(done)} · verify-fail ${esc(fail)}</span>`;
|
|
628
|
+
};
|
|
629
|
+
const acceptance = `<h3>Acceptance rate — Done ÷ (Done + verify-fail)</h3>`
|
|
630
|
+
+ metricRow("last 7d", acceptVal(done7, fail7)) + metricRow("last 30d", acceptVal(done30, fail30));
|
|
631
|
+
const actorSection = `<h3>Per-actor activity — last 30 days</h3>`
|
|
632
|
+
+ (actors.length ? actors.map((a) => metricRow(a.actor, `<b>${esc(a.n)}</b> event${Number(a.n) === 1 ? "" : "s"}`)).join("") : `<p class="empty">No activity in the last 30 days.</p>`);
|
|
633
|
+
const cycleSection = `<h3>Cycle time — recently Done</h3>`
|
|
634
|
+
+ (cycle.length ? cycle.map((c) => `<div class="rlevel"><span class="rkey">${esc(c.tid)}</span><span>cycle <b>${esc(c.label)}</b> · Done ${esc(c.done)}</span></div>`).join("") : `<p class="empty">No tickets reached Done in the last 30 days.</p>`);
|
|
635
|
+
// DL-84 — per-stage breakdown of that cycle time: median residence in each stage over the SAME recently-Done
|
|
636
|
+
// set, so the operator can see WHICH stage is the bottleneck. A high Todo (queue-wait) points at Dev throughput;
|
|
637
|
+
// a high In Review (verify-lag) points at the OWNER agents (PM/QA) not verifying finished work — distinct from
|
|
638
|
+
// DL-79's acceptance-rate (verify-*fails*, not verify-*waits*). A stage with no qualifying ticket renders "—"
|
|
639
|
+
// (never a fake 0 / divide-by-zero); each median shows its n (DL-79 raw-count parity).
|
|
640
|
+
const STAGE_LABELS = {
|
|
641
|
+
"Todo": "Todo — queue-wait (awaiting Dev pickup)",
|
|
642
|
+
"In Progress": "In Progress — build (Dev)",
|
|
643
|
+
"In Review": "In Review — verify-lag (awaiting owner PM/QA verify)",
|
|
644
|
+
};
|
|
645
|
+
const stageRow = (st) => {
|
|
646
|
+
const list = stageLists[st], med = median(list);
|
|
647
|
+
return metricRow(STAGE_LABELS[st], med === undefined
|
|
648
|
+
? `<span class="sub">— no data</span>`
|
|
649
|
+
: `<b>${esc(humanDur(med))}</b> <span class="sub">n ${esc(list.length)}</span>`);
|
|
650
|
+
};
|
|
651
|
+
const stageSection = `<h3>Time in stage — recently Done (median)</h3>` + STAGES.map(stageRow).join("");
|
|
652
|
+
// DL-89 — Open WIP aging: per active state, the currently-open tickets ordered oldest-first by how long they
|
|
653
|
+
// have sat in that state RIGHT NOW — a forward-looking "now" snapshot complementing the backward-looking stage
|
|
654
|
+
// medians above. Age = now − the latest transition INTO the current state, falling back to issue.create when the
|
|
655
|
+
// ticket never transitioned in (AC2). Read-only over the same tickets/events the page already uses (no new table,
|
|
656
|
+
// no write route); the open-WIP set is small (that's the point), so the per-ticket hist query mirrors cycle-time.
|
|
657
|
+
const wipAll = [...WIP_CORE_STATES, ...WIP_PARK_STATES];
|
|
658
|
+
const openRows = db.prepare(`SELECT id,state FROM tickets WHERE project_id=? AND state IN (${wipAll.map(() => "?").join(",")})`).all(projectId, ...wipAll);
|
|
659
|
+
const openByState = new Map();
|
|
660
|
+
for (const r of openRows) {
|
|
661
|
+
const hist = db.prepare(HIST_SQL).all(projectId, r.id);
|
|
662
|
+
let into, created;
|
|
663
|
+
for (const e of hist) {
|
|
664
|
+
if (e.kind === "issue.create") {
|
|
665
|
+
if (created === undefined)
|
|
666
|
+
created = e.created_at;
|
|
667
|
+
}
|
|
668
|
+
else if (eventData(e.data).to === r.state)
|
|
669
|
+
into = e.created_at; // ASC by id → the LAST match is the latest into-state transition (AC2)
|
|
670
|
+
}
|
|
671
|
+
const since = into ?? created; // fallback to create when never transitioned into this state (AC2)
|
|
672
|
+
if (!openByState.has(r.state))
|
|
673
|
+
openByState.set(r.state, []);
|
|
674
|
+
openByState.get(r.state).push({ id: r.id, sinceMs: since ? Date.parse(since) : NaN });
|
|
675
|
+
}
|
|
676
|
+
const wipFlag = (state, ageMs) => state === "In Review" && ageMs > WIP_VERIFY_LAG_MS ? ` <span class="warn">⚠ verify-lag</span>` // owner (PM/QA) not verifying finished work
|
|
677
|
+
: state === "In Progress" && ageMs > WIP_ORPHAN_MS ? ` <span class="warn">⚠ possible-orphan</span>` // a claim outliving its Dev fire (beyond Sweep's reclaim)
|
|
678
|
+
: ""; // Human-Blocked is a deliberate park — shown, never flagged
|
|
679
|
+
const wipBlock = (state) => {
|
|
680
|
+
const list = (openByState.get(state) ?? []).sort((a, b) => a.sinceMs - b.sinceMs); // oldest-first = earliest into-state timestamp first (AC1)
|
|
681
|
+
const head = metricRow(state, `<span class="sub">${list.length ? `${esc(list.length)} open` : "— none"}</span>`); // AC4: no open tickets → a neutral "— none", never a fake 0
|
|
682
|
+
return head + list.map((t) => { const ageMs = nowMs - t.sinceMs; return metricRow(t.id, `<b>${esc(humanDur(ageMs))}</b>${wipFlag(state, ageMs)}`); }).join("");
|
|
683
|
+
};
|
|
684
|
+
// core states always render (— none when empty); a park state only when populated (see the WIP_*_STATES consts).
|
|
685
|
+
const wipStates = [...WIP_CORE_STATES, ...WIP_PARK_STATES.filter((s) => openByState.get(s)?.length)];
|
|
686
|
+
const openWipSection = `<h3>Open WIP — aging</h3>` + wipStates.map(wipBlock).join("");
|
|
687
|
+
const feedSection = `<h3>Recent activity<span class="count" style="margin-left:.4rem">${feed.length}</span></h3>`
|
|
688
|
+
+ (feed.length ? feed.map(eventLine).join("") : `<p class="empty">No activity recorded yet.</p>`);
|
|
689
|
+
return `<a class="back" href="/">← board</a><article class="detail"><h1>Activity</h1>`
|
|
690
|
+
+ throughput + acceptance + actorSection + cycleSection + stageSection + openWipSection + feedSection + `</article>`;
|
|
691
|
+
}
|