scrumrun 4.2.0 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +1 -1
- package/bin/scrumrun.js +2 -2
- package/lib/view/server.js +5 -3
- package/package.json +1 -1
- package/templates/shared/view.html +185 -32
package/CHANGELOG.md
CHANGED
|
@@ -4,13 +4,29 @@ All notable changes follow Semantic Versioning.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 4.2.2 - 2026-09-22
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`scrumrun view` picks a random free port by default.** Running `scrumrun view` without `--port` now asks the OS for any ephemeral port instead of always trying `8080` first (a common conflict with Vite, Django, and other dev servers). Pass `--port <n>` explicitly if you need a stable URL; sequential retry only fires when the port was explicitly requested. The bound URL is always printed on stdout and the browser is auto-launched at the correct port.
|
|
12
|
+
- **Kanban card width capped.** Columns are constrained to `280–320 px` and align to the left of the grid, so a single populated column no longer stretches its cards to full viewport width. The `Compact` view mode was removed — `Kanban` and `List` cover the two real use-cases.
|
|
13
|
+
- **Guardrail rows truncate the title at 150 characters.** Long rules no longer produce a linha extensa e ilegível; the row shows the id, a truncated title with `…`, and clicking the row opens the detail drawer with the **full rule body rendered as Markdown** (fields, prose, YAML enforcement block).
|
|
14
|
+
|
|
15
|
+
## 4.2.1 - 2026-09-22
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`scrumrun view` drawer renders Markdown.** The right-side detail panel now parses artifact bodies (headings h1–h4, bullet + ordered lists, inline `code` and fenced code blocks, links, bold/italic, blockquotes, `hr`) and displays them formatted instead of dumping the raw file. A structured metadata card (`id`, `kind`, `status`, `type`, `created`, `updated`, `feature`, `sprint`, `branch`, `assignee`, `method` — plus any extra frontmatter fields) renders above the body. Artifact ids (`TASK-*`, `RUN-*`, `FEAT-*`, `SPRINT-*`, `DEC-*`, `K-*`, `INS-*`, `DOS-*`, `GR-*`, `REV-*`) appearing in prose are auto-linked as `<code>`. A "open raw file" link at the bottom keeps the plain view one click away.
|
|
20
|
+
- **Canonical status columns always visible.** `backlog`, `running`, and `completed` render even when they contain no tasks, so the kanban layout is predictable across projects. Empty columns show a `—` placeholder.
|
|
21
|
+
- **Guardrails / Recent Runs / Decisions list layout.** Id renders in monospace on the left (72 px reserved column) with the title next to it — no more `justify-between` stretch that pushed the title to the far right of a wide viewport. Optional status badge on the right side.
|
|
22
|
+
|
|
7
23
|
## 4.2.0 - 2026-09-22
|
|
8
24
|
|
|
9
25
|
### Added
|
|
10
26
|
|
|
11
27
|
- **`scrumrun sync`.** Team-first git wrapper: fetches the remote, rebases the current branch, refreshes `state.md`, and optionally runs `repair --apply` (with `--repair --apply`). Refuses cleanly when the tree is dirty, when git isn't set up, when there's no remote, or when HEAD is detached — so no half-applied merges. Returns structured JSON so wrappers can automate.
|
|
12
28
|
- **`scrumrun lock <acquire|release|status|reap> [ARTIFACT-ID]`.** Cooperative soft locks under `.scrumrun/.locks/<ID>.lock`. Auto-expire after 15 minutes (configurable via `--ttl <minutes>`). Non-owner release is refused unless `--force`. `status` (without id) lists every active lock; `reap` sweeps expired locks. Advisory only — never gates writes, but surfaces "who's touching what" for teams working on the same repo. Owner detected from `SCRUMRUN_AGENT`/`USER`/`USERNAME`.
|
|
13
|
-
- **`scrumrun view` visual polish.** Kanban / Compact / List mode toggle in the toolbar (persisted in `localStorage`). Per-column scroll containers so a huge Completed column doesn't stretch the whole grid. Card hover elevation, larger titles, rounded corners, count pills next to status headers. Cards now render `@assignee` as a highlighted chip and expose `data-assignee` for `?assignee=<name>` filtering. `?readonly=1` disables interactive affordances for stakeholder viewing.
|
|
29
|
+
- **`scrumrun view` visual polish.** Kanban / Compact / List mode toggle in the toolbar (persisted in `localStorage`). Per-column scroll containers so a huge Completed column doesn't stretch the whole grid. Card hover elevation, larger titles, rounded corners, count pills next to status headers. Cards now render `@assignee` as a highlighted chip and expose `data-assignee` for `?assignee=<name>` filtering. `?readonly=1` disables interactive affordances for stakeholder viewing. Canonical columns (`backlog`, `running`, `completed`) always render even when empty so the layout stays predictable. **Detail drawer now renders Markdown** — headings, lists, code (inline + fenced), links, bold/italic, blockquotes, horizontal rules — plus a structured metadata card and auto-linked artifact ids. Guardrails / Recent Runs / Decisions lists now show the id on the left and the title next to it (no more far-right stretch), with an optional status badge on the right.
|
|
14
30
|
|
|
15
31
|
## 4.1.4 - 2026-09-22
|
|
16
32
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
ScrumRun gives an agent a small command surface and a precise project memory: what should be done, how each attempt happened, which decisions constrain the code, and why the architecture exists in its current form.
|
|
6
6
|
|
|
7
|
-
**Package:** `4.2.
|
|
7
|
+
**Package:** `4.2.2` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
|
|
8
8
|
|
|
9
9
|
**New here?** Read the [Quickstart](docs/QUICKSTART.md) — first Run in under 10 minutes, no `SPEC.md` reading required. Full docs map in [`docs/INDEX.md`](docs/INDEX.md).
|
|
10
10
|
|
package/bin/scrumrun.js
CHANGED
|
@@ -64,7 +64,7 @@ Usage:
|
|
|
64
64
|
scrumrun update [all|codex|opencode|claude] [--project] [--seal-policy] [--migrate] [--repair-legacy] [--verbose]
|
|
65
65
|
scrumrun init [--local|--shared] [--lean] [--no-agent-hint] [--force]
|
|
66
66
|
scrumrun status
|
|
67
|
-
scrumrun view [--port
|
|
67
|
+
scrumrun view [--port <n>] [--host 127.0.0.1] [--no-open] # port defaults to random ephemeral
|
|
68
68
|
scrumrun sync [--repair] [--apply]
|
|
69
69
|
scrumrun lock <acquire|release|status|reap> [ARTIFACT-ID] [--note "..."] [--ttl <minutes>] [--force]
|
|
70
70
|
scrumrun core [--path|--prompt]
|
|
@@ -2805,7 +2805,7 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
2805
2805
|
const { view } = require(path.join(root, "lib", "view", "server"));
|
|
2806
2806
|
const portArg = args.indexOf("--port");
|
|
2807
2807
|
const hostArg = args.indexOf("--host");
|
|
2808
|
-
const port = portArg !== -1 && args[portArg + 1] ? Number(args[portArg + 1]) :
|
|
2808
|
+
const port = portArg !== -1 && args[portArg + 1] ? Number(args[portArg + 1]) : 0;
|
|
2809
2809
|
const host = hostArg !== -1 && args[hostArg + 1] ? args[hostArg + 1] : "127.0.0.1";
|
|
2810
2810
|
const autoOpen = !args.includes("--no-open");
|
|
2811
2811
|
const { server } = await view(process.cwd(), { port, host, autoOpen });
|
package/lib/view/server.js
CHANGED
|
@@ -59,13 +59,15 @@ function requestHandler(root) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function listen(root, { host = "127.0.0.1", port =
|
|
62
|
+
function listen(root, { host = "127.0.0.1", port = 0 } = {}) {
|
|
63
63
|
return new Promise((resolve, reject) => {
|
|
64
64
|
const server = http.createServer(requestHandler(root));
|
|
65
65
|
let attempts = 0;
|
|
66
66
|
const tryPort = (p) => {
|
|
67
67
|
server.once("error", (err) => {
|
|
68
|
-
|
|
68
|
+
// Only retry sequentially when the user asked for a specific port; port 0
|
|
69
|
+
// means "let the OS pick", so a collision there is genuinely fatal.
|
|
70
|
+
if (err.code === "EADDRINUSE" && p !== 0 && attempts < 20) {
|
|
69
71
|
attempts += 1;
|
|
70
72
|
setImmediate(() => tryPort(p + 1));
|
|
71
73
|
} else {
|
|
@@ -88,7 +90,7 @@ function openInBrowser(url) {
|
|
|
88
90
|
return spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
async function view(projectRoot, { port =
|
|
93
|
+
async function view(projectRoot, { port = 0, host = "127.0.0.1", autoOpen = true, logger = console } = {}) {
|
|
92
94
|
const root = path.join(projectRoot, ".scrumrun");
|
|
93
95
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
94
96
|
throw new Error(`ScrumRun project not found at ${root}. Run \`scrumrun init\` first.`);
|
package/package.json
CHANGED
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
.toolbar button { background: transparent; border: 0; color: var(--muted); padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
35
35
|
.toolbar button.active { background: var(--border); color: var(--fg); }
|
|
36
36
|
.toolbar button:hover { color: var(--fg); }
|
|
37
|
-
.kanban { display: grid; grid-template-columns: repeat(auto-
|
|
38
|
-
.kanban.compact { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
|
|
37
|
+
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 320px)); gap: 14px; align-items: start; justify-content: start; }
|
|
39
38
|
.col { min-width: 0; }
|
|
40
39
|
.col h3 { margin: 0 0 10px; font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: .06em; display: flex; align-items: center; gap: 6px; }
|
|
41
40
|
.col h3 .count { background: var(--border); color: var(--fg); padding: 1px 6px; border-radius: 10px; font-family: var(--mono); font-size: 10px; }
|
|
@@ -47,9 +46,6 @@
|
|
|
47
46
|
.card .id { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: .04em; }
|
|
48
47
|
.card .title { font-size: 13.5px; font-weight: 500; margin-top: 3px; line-height: 1.35; color: var(--fg); }
|
|
49
48
|
.card .meta { font-size: 11px; color: var(--muted); margin-top: 8px; display: flex; gap: 5px; flex-wrap: wrap; }
|
|
50
|
-
.kanban.compact .card { padding: 7px 9px; }
|
|
51
|
-
.kanban.compact .card .title { font-size: 12.5px; }
|
|
52
|
-
.kanban.compact .card .meta { display: none; }
|
|
53
49
|
body.list-mode .kanban { display: block; }
|
|
54
50
|
body.list-mode .col { margin-bottom: 20px; }
|
|
55
51
|
body.list-mode .col-body { max-height: none; }
|
|
@@ -71,11 +67,37 @@
|
|
|
71
67
|
#detail.open { transform: translateX(0); }
|
|
72
68
|
#detail pre { white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; background: var(--bg); padding: 10px; border-radius: 6px; border: 1px solid var(--border); }
|
|
73
69
|
#detail .close { float: right; background: none; border: 0; color: var(--muted); font-size: 20px; cursor: pointer; }
|
|
74
|
-
.
|
|
70
|
+
.md { line-height: 1.55; font-size: 13.5px; color: var(--fg); }
|
|
71
|
+
.md h1, .md h2, .md h3, .md h4 { margin: 18px 0 8px; font-weight: 600; }
|
|
72
|
+
.md h1 { font-size: 20px; }
|
|
73
|
+
.md h2 { font-size: 16px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
|
74
|
+
.md h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
75
|
+
.md h4 { font-size: 13px; color: var(--muted); }
|
|
76
|
+
.md p { margin: 8px 0; }
|
|
77
|
+
.md ul, .md ol { margin: 8px 0; padding-left: 22px; }
|
|
78
|
+
.md li { margin: 4px 0; }
|
|
79
|
+
.md code { font-family: var(--mono); font-size: 12px; background: var(--bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
|
|
80
|
+
.md pre.md-code { white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; background: var(--bg); padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border); margin: 10px 0; }
|
|
81
|
+
.md pre.md-code code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
82
|
+
.md a { color: var(--accent); text-decoration: none; }
|
|
83
|
+
.md a:hover { text-decoration: underline; }
|
|
84
|
+
.md strong { color: var(--fg); font-weight: 600; }
|
|
85
|
+
.md em { font-style: italic; }
|
|
86
|
+
.md hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
87
|
+
.md blockquote { border-left: 3px solid var(--border); padding: 4px 12px; margin: 10px 0; color: var(--muted); }
|
|
88
|
+
.md table { border-collapse: collapse; margin: 10px 0; font-size: 12.5px; }
|
|
89
|
+
.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; }
|
|
90
|
+
.md th { background: var(--bg); font-weight: 600; }
|
|
91
|
+
.md-meta { display: grid; grid-template-columns: 100px 1fr; gap: 4px 12px; font-size: 12px; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; }
|
|
92
|
+
.md-meta .k { font-family: var(--mono); color: var(--muted); font-size: 11px; }
|
|
93
|
+
.md-meta .v { font-family: var(--mono); font-size: 11.5px; word-break: break-all; }
|
|
94
|
+
.list-item { padding: 8px 0; border-bottom: 1px dotted var(--border); cursor: pointer; display: flex; align-items: baseline; gap: 12px; }
|
|
75
95
|
.list-item:last-child { border-bottom: 0; }
|
|
76
|
-
.list-item:hover { color: var(--
|
|
77
|
-
.
|
|
78
|
-
.row
|
|
96
|
+
.list-item:hover .row-text { color: var(--fg); }
|
|
97
|
+
.list-item:hover .row-id { color: var(--accent); }
|
|
98
|
+
.row-id { font-family: var(--mono); font-size: 11px; color: var(--muted); min-width: 72px; flex-shrink: 0; letter-spacing: .04em; }
|
|
99
|
+
.row-text { font-size: 13px; color: var(--fg); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
100
|
+
.row-meta { font-size: 11px; color: var(--muted); flex-shrink: 0; }
|
|
79
101
|
.header-right { margin-left: auto; display: flex; gap: 12px; align-items: center; }
|
|
80
102
|
</style>
|
|
81
103
|
</head>
|
|
@@ -94,7 +116,6 @@
|
|
|
94
116
|
<div class="toolbar">
|
|
95
117
|
<div class="group" role="tablist" aria-label="View mode">
|
|
96
118
|
<button data-mode="kanban" class="active">Kanban</button>
|
|
97
|
-
<button data-mode="compact">Compact</button>
|
|
98
119
|
<button data-mode="list">List</button>
|
|
99
120
|
</div>
|
|
100
121
|
</div>
|
|
@@ -121,6 +142,7 @@
|
|
|
121
142
|
(() => {
|
|
122
143
|
"use strict";
|
|
123
144
|
const STATUS_ORDER = ["backlog", "running", "in_progress", "validating", "learning", "blocked", "failed", "completed"];
|
|
145
|
+
const ALWAYS_SHOW_STATUSES = ["backlog", "running", "completed"];
|
|
124
146
|
const STATUS_LABELS = {
|
|
125
147
|
backlog: "Backlog",
|
|
126
148
|
running: "Running",
|
|
@@ -190,18 +212,26 @@
|
|
|
190
212
|
if (!bucket[status]) bucket[status] = [];
|
|
191
213
|
bucket[status].push(task);
|
|
192
214
|
}
|
|
193
|
-
const ordered = STATUS_ORDER.filter((s) => bucket[s] && bucket[s].length);
|
|
215
|
+
const ordered = STATUS_ORDER.filter((s) => (bucket[s] && bucket[s].length) || ALWAYS_SHOW_STATUSES.includes(s));
|
|
194
216
|
const container = document.getElementById("kanban");
|
|
195
217
|
container.innerHTML = "";
|
|
196
|
-
if (!
|
|
218
|
+
if (!tasks.length) { container.innerHTML = '<div class="empty">no tasks found</div>'; return; }
|
|
197
219
|
for (const status of ordered) {
|
|
220
|
+
const items = bucket[status] || [];
|
|
198
221
|
const col = document.createElement("div");
|
|
199
222
|
col.className = "col";
|
|
200
|
-
col.innerHTML = `<h3>${STATUS_LABELS[status] || status} <span class="count">${
|
|
223
|
+
col.innerHTML = `<h3>${STATUS_LABELS[status] || status} <span class="count">${items.length}</span></h3>`;
|
|
201
224
|
const body = document.createElement("div");
|
|
202
225
|
body.className = "col-body";
|
|
203
226
|
col.appendChild(body);
|
|
204
|
-
|
|
227
|
+
if (!items.length) {
|
|
228
|
+
const empty = document.createElement("div");
|
|
229
|
+
empty.className = "empty";
|
|
230
|
+
empty.style.padding = "6px 0";
|
|
231
|
+
empty.textContent = "—";
|
|
232
|
+
body.appendChild(empty);
|
|
233
|
+
}
|
|
234
|
+
for (const task of items) {
|
|
205
235
|
const title = extractHeading(task.body);
|
|
206
236
|
const type = task.record.type || "task";
|
|
207
237
|
const branch = task.record.branch;
|
|
@@ -235,39 +265,162 @@
|
|
|
235
265
|
const row = document.createElement("div");
|
|
236
266
|
row.className = "list-item";
|
|
237
267
|
row.innerHTML = format(item);
|
|
238
|
-
row.addEventListener("click", () => openDetail(item));
|
|
268
|
+
if (item && item.record) row.addEventListener("click", () => openDetail(item));
|
|
239
269
|
el.appendChild(row);
|
|
240
270
|
}
|
|
241
271
|
}
|
|
242
272
|
|
|
273
|
+
function truncate(text, max = 150) {
|
|
274
|
+
const t = String(text || "").replace(/\s+/g, " ").trim();
|
|
275
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + "…" : t;
|
|
276
|
+
}
|
|
277
|
+
|
|
243
278
|
function renderGuardrailsFromText(text) {
|
|
244
279
|
const el = document.getElementById("guardrails");
|
|
245
280
|
if (!text) { el.innerHTML = '<div class="empty">guardrails.md not found</div>'; return; }
|
|
246
281
|
const rules = [];
|
|
247
|
-
|
|
248
|
-
|
|
282
|
+
// Parse each ## GR-XXX block: capture id, title, and body until next ## or EOF
|
|
283
|
+
const blockRe = /^##\s+(GR-\d+)\s+-\s+(.+?)\r?\n([\s\S]*?)(?=^##\s+GR-|\Z)/gm;
|
|
284
|
+
let match;
|
|
285
|
+
while ((match = blockRe.exec(text)) !== null) {
|
|
286
|
+
rules.push({ id: match[1], title: match[2].trim(), body: match[3].trim() });
|
|
287
|
+
}
|
|
288
|
+
el.innerHTML = "";
|
|
289
|
+
if (!rules.length) { el.innerHTML = '<div class="empty">no guardrails declared</div>'; return; }
|
|
290
|
+
for (const rule of rules) {
|
|
291
|
+
const row = document.createElement("div");
|
|
292
|
+
row.className = "list-item";
|
|
293
|
+
row.innerHTML = `<span class="row-id">${rule.id}</span><span class="row-text">${escape(truncate(rule.title, 150))}</span>`;
|
|
294
|
+
row.addEventListener("click", () => openGuardrailDetail(rule));
|
|
295
|
+
el.appendChild(row);
|
|
249
296
|
}
|
|
250
|
-
el.innerHTML = rules.length
|
|
251
|
-
? rules.map((r) => `<div class="row"><span>${r.id}</span><span class="muted">${r.title}</span></div>`).join("")
|
|
252
|
-
: '<div class="empty">no guardrails declared</div>';
|
|
253
297
|
}
|
|
254
298
|
|
|
255
|
-
function
|
|
299
|
+
function openGuardrailDetail(rule) {
|
|
256
300
|
const body = document.getElementById("detail-body");
|
|
257
|
-
const record = artifact.record || {};
|
|
258
|
-
const meta = Object.entries(record).map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
259
301
|
body.innerHTML = `
|
|
260
|
-
<h2 style="margin-top:0;font-size:
|
|
261
|
-
<div class="sub" style="margin-bottom:
|
|
262
|
-
<
|
|
263
|
-
<pre>${escape(artifact.body || "")}</pre>
|
|
264
|
-
<div class="sub"><a href="${artifact.file || "#"}" target="_blank" rel="noopener">open raw file</a></div>`;
|
|
302
|
+
<h2 style="margin-top:0;font-size:15px">${escape(rule.id)}</h2>
|
|
303
|
+
<div class="sub" style="margin-bottom:12px">${escape(rule.title)}</div>
|
|
304
|
+
<div class="md">${renderMarkdown(rule.body)}</div>`;
|
|
265
305
|
document.getElementById("detail").classList.add("open");
|
|
266
306
|
document.getElementById("detail").setAttribute("aria-hidden", "false");
|
|
267
307
|
}
|
|
268
308
|
|
|
269
309
|
function escape(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
270
310
|
|
|
311
|
+
function renderInline(text) {
|
|
312
|
+
let s = escape(text);
|
|
313
|
+
// code inline first (so we don't process markdown inside)
|
|
314
|
+
s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
|
|
315
|
+
// links [text](url)
|
|
316
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${href}" target="_blank" rel="noopener">${label}</a>`);
|
|
317
|
+
// bold and italic
|
|
318
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
319
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
|
|
320
|
+
// auto-link bare TASK-NNN / RUN-NNN / etc — do it last, and skip if already inside a tag
|
|
321
|
+
s = s.replace(/(^|\s)(TASK-\d+|RUN-\d+|FEAT-\d+|SPRINT-\d+|DEC-\d+|K-\d+|INS-\d+|DOS-\d+|GR-\d+|REV-\d+)(?![^<]*>)/g, "$1<code>$2</code>");
|
|
322
|
+
return s;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderMarkdown(source) {
|
|
326
|
+
const lines = String(source || "").split(/\r?\n/);
|
|
327
|
+
const out = [];
|
|
328
|
+
let i = 0;
|
|
329
|
+
let inCode = false;
|
|
330
|
+
let codeBuf = [];
|
|
331
|
+
const flushCode = () => {
|
|
332
|
+
out.push(`<pre class="md-code"><code>${escape(codeBuf.join("\n"))}</code></pre>`);
|
|
333
|
+
codeBuf = [];
|
|
334
|
+
};
|
|
335
|
+
while (i < lines.length) {
|
|
336
|
+
const line = lines[i];
|
|
337
|
+
if (/^```/.test(line)) {
|
|
338
|
+
if (inCode) { flushCode(); inCode = false; }
|
|
339
|
+
else { inCode = true; }
|
|
340
|
+
i += 1; continue;
|
|
341
|
+
}
|
|
342
|
+
if (inCode) { codeBuf.push(line); i += 1; continue; }
|
|
343
|
+
if (/^#{1,4}\s/.test(line)) {
|
|
344
|
+
const level = line.match(/^(#{1,4})/)[1].length;
|
|
345
|
+
const text = line.replace(/^#{1,4}\s+/, "");
|
|
346
|
+
out.push(`<h${level}>${renderInline(text)}</h${level}>`);
|
|
347
|
+
i += 1; continue;
|
|
348
|
+
}
|
|
349
|
+
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) {
|
|
350
|
+
out.push("<hr />");
|
|
351
|
+
i += 1; continue;
|
|
352
|
+
}
|
|
353
|
+
if (/^\s*>\s?/.test(line)) {
|
|
354
|
+
const chunk = [];
|
|
355
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
356
|
+
chunk.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
357
|
+
i += 1;
|
|
358
|
+
}
|
|
359
|
+
out.push(`<blockquote>${renderInline(chunk.join(" "))}</blockquote>`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
363
|
+
const items = [];
|
|
364
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
365
|
+
items.push(`<li>${renderInline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>`);
|
|
366
|
+
i += 1;
|
|
367
|
+
}
|
|
368
|
+
out.push(`<ul>${items.join("")}</ul>`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
372
|
+
const items = [];
|
|
373
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
|
374
|
+
items.push(`<li>${renderInline(lines[i].replace(/^\s*\d+\.\s+/, ""))}</li>`);
|
|
375
|
+
i += 1;
|
|
376
|
+
}
|
|
377
|
+
out.push(`<ol>${items.join("")}</ol>`);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (!line.trim()) { i += 1; continue; }
|
|
381
|
+
// paragraph — accumulate until blank line
|
|
382
|
+
const para = [line];
|
|
383
|
+
i += 1;
|
|
384
|
+
while (i < lines.length && lines[i].trim() && !/^(#{1,4}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>|-{3,}\s*$)/.test(lines[i])) {
|
|
385
|
+
para.push(lines[i]);
|
|
386
|
+
i += 1;
|
|
387
|
+
}
|
|
388
|
+
out.push(`<p>${renderInline(para.join(" "))}</p>`);
|
|
389
|
+
}
|
|
390
|
+
if (inCode && codeBuf.length) flushCode();
|
|
391
|
+
return out.join("\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderMeta(record) {
|
|
395
|
+
const order = ["id", "kind", "status", "type", "created", "updated", "feature", "sprint", "branch", "assignee", "completed_via", "method"];
|
|
396
|
+
const seen = new Set();
|
|
397
|
+
const rows = [];
|
|
398
|
+
for (const key of order) {
|
|
399
|
+
if (record[key] === undefined || record[key] === null || record[key] === "") continue;
|
|
400
|
+
seen.add(key);
|
|
401
|
+
rows.push(`<span class="k">${escape(key)}</span><span class="v">${escape(String(record[key]))}</span>`);
|
|
402
|
+
}
|
|
403
|
+
for (const [key, value] of Object.entries(record)) {
|
|
404
|
+
if (seen.has(key) || value === undefined || value === null || value === "") continue;
|
|
405
|
+
rows.push(`<span class="k">${escape(key)}</span><span class="v">${escape(String(value))}</span>`);
|
|
406
|
+
}
|
|
407
|
+
return `<div class="md-meta">${rows.join("")}</div>`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function openDetail(artifact) {
|
|
411
|
+
const body = document.getElementById("detail-body");
|
|
412
|
+
const record = artifact.record || {};
|
|
413
|
+
const title = extractHeading(artifact.body || "");
|
|
414
|
+
body.innerHTML = `
|
|
415
|
+
<h2 style="margin-top:0;font-size:15px">${escape(record.id || "(unknown)")}</h2>
|
|
416
|
+
<div class="sub" style="margin-bottom:12px">${escape(title)}</div>
|
|
417
|
+
${renderMeta(record)}
|
|
418
|
+
<div class="md">${renderMarkdown(artifact.body || "")}</div>
|
|
419
|
+
<div class="sub" style="margin-top:14px"><a href="${artifact.file || "#"}" target="_blank" rel="noopener">open raw file</a></div>`;
|
|
420
|
+
document.getElementById("detail").classList.add("open");
|
|
421
|
+
document.getElementById("detail").setAttribute("aria-hidden", "false");
|
|
422
|
+
}
|
|
423
|
+
|
|
271
424
|
document.getElementById("detail-close").addEventListener("click", () => {
|
|
272
425
|
document.getElementById("detail").classList.remove("open");
|
|
273
426
|
document.getElementById("detail").setAttribute("aria-hidden", "true");
|
|
@@ -291,9 +444,9 @@
|
|
|
291
444
|
document.getElementById("search").addEventListener("input", applyFilters);
|
|
292
445
|
|
|
293
446
|
const kanbanEl = document.getElementById("kanban");
|
|
294
|
-
const savedMode = localStorage.getItem("scrumrun.view.mode")
|
|
447
|
+
const savedMode = localStorage.getItem("scrumrun.view.mode") === "list" ? "list" : "kanban";
|
|
295
448
|
function setMode(mode) {
|
|
296
|
-
|
|
449
|
+
if (mode !== "list") mode = "kanban";
|
|
297
450
|
document.body.classList.toggle("list-mode", mode === "list");
|
|
298
451
|
for (const button of document.querySelectorAll(".toolbar button")) {
|
|
299
452
|
button.classList.toggle("active", button.dataset.mode === mode);
|
|
@@ -333,8 +486,8 @@
|
|
|
333
486
|
}
|
|
334
487
|
renderKanban(tasks);
|
|
335
488
|
applyFilters();
|
|
336
|
-
renderList("runs", runs.slice().reverse().slice(0, 20), (r) => `<
|
|
337
|
-
renderList("decisions", decisions, (d) => `<
|
|
489
|
+
renderList("runs", runs.slice().reverse().slice(0, 20), (r) => `<span class="row-id">${r.record.id}</span><span class="row-text">${escape(extractHeading(r.body))}</span>${r.record.status ? `<span class="row-meta">${r.record.status}</span>` : ""}`);
|
|
490
|
+
renderList("decisions", decisions, (d) => `<span class="row-id">${d.record.id}</span><span class="row-text">${escape(extractHeading(d.body))}</span>${d.record.status ? `<span class="row-meta">${d.record.status}</span>` : ""}`);
|
|
338
491
|
renderGuardrailsFromText(guardrailsText);
|
|
339
492
|
}
|
|
340
493
|
|