scrumrun 4.1.4 → 4.2.1
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 +16 -0
- package/README.md +1 -1
- package/bin/scrumrun.js +49 -0
- package/lib/team/locks.js +117 -0
- package/lib/team/sync.js +115 -0
- package/package.json +1 -1
- package/templates/shared/view.html +233 -32
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes follow Semantic Versioning.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 4.2.1 - 2026-09-22
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
## 4.2.0 - 2026-09-22
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`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.
|
|
20
|
+
- **`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`.
|
|
21
|
+
- **`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.
|
|
22
|
+
|
|
7
23
|
## 4.1.4 - 2026-09-22
|
|
8
24
|
|
|
9
25
|
### Added
|
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.1
|
|
7
|
+
**Package:** `4.2.1` · **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
|
@@ -65,6 +65,8 @@ Usage:
|
|
|
65
65
|
scrumrun init [--local|--shared] [--lean] [--no-agent-hint] [--force]
|
|
66
66
|
scrumrun status
|
|
67
67
|
scrumrun view [--port 8080] [--host 127.0.0.1] [--no-open]
|
|
68
|
+
scrumrun sync [--repair] [--apply]
|
|
69
|
+
scrumrun lock <acquire|release|status|reap> [ARTIFACT-ID] [--note "..."] [--ttl <minutes>] [--force]
|
|
68
70
|
scrumrun core [--path|--prompt]
|
|
69
71
|
scrumrun commands
|
|
70
72
|
scrumrun migrate --to 2 --dry-run
|
|
@@ -2750,6 +2752,53 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
2750
2752
|
} else {
|
|
2751
2753
|
initProject({ force, mode: shared ? "shared" : "local", agentHint: shared || !noAgentHint, lean });
|
|
2752
2754
|
}
|
|
2755
|
+
} else if (command === "sync") {
|
|
2756
|
+
try {
|
|
2757
|
+
const { sync } = require(path.join(root, "lib", "team", "sync"));
|
|
2758
|
+
const result = sync(process.cwd(), {
|
|
2759
|
+
autoRepair: args.includes("--repair"),
|
|
2760
|
+
apply: args.includes("--apply")
|
|
2761
|
+
});
|
|
2762
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2763
|
+
if (["dirty", "fetch-failed", "rebase-conflict", "no-project", "no-git", "no-remote", "detached"].includes(result.status)) {
|
|
2764
|
+
process.exitCode = 1;
|
|
2765
|
+
}
|
|
2766
|
+
} catch (error) {
|
|
2767
|
+
console.error(`sync failed: ${error.message}`);
|
|
2768
|
+
process.exitCode = 1;
|
|
2769
|
+
}
|
|
2770
|
+
} else if (command === "lock") {
|
|
2771
|
+
try {
|
|
2772
|
+
const locks = require(path.join(root, "lib", "team", "locks"));
|
|
2773
|
+
const scrumDir = path.join(process.cwd(), ".scrumrun");
|
|
2774
|
+
if (!fs.existsSync(scrumDir)) throw new Error("ScrumRun project not initialized.");
|
|
2775
|
+
const action = args[1];
|
|
2776
|
+
const id = args[2];
|
|
2777
|
+
if (action === "--status" || action === "status") {
|
|
2778
|
+
console.log(JSON.stringify(locks.status(scrumDir, id), null, 2));
|
|
2779
|
+
} else if (action === "--acquire" || action === "acquire") {
|
|
2780
|
+
if (!id) throw new Error("Usage: scrumrun lock acquire <ARTIFACT-ID> [--note \"...\"] [--ttl <minutes>]");
|
|
2781
|
+
const noteIdx = args.indexOf("--note");
|
|
2782
|
+
const ttlIdx = args.indexOf("--ttl");
|
|
2783
|
+
const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
|
|
2784
|
+
const ttlMs = ttlIdx !== -1 ? Number(args[ttlIdx + 1]) * 60 * 1000 : undefined;
|
|
2785
|
+
const result = locks.acquire(scrumDir, id, { note, ttlMs });
|
|
2786
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2787
|
+
if (result.status === "conflict") process.exitCode = 1;
|
|
2788
|
+
} else if (action === "--release" || action === "release") {
|
|
2789
|
+
if (!id) throw new Error("Usage: scrumrun lock release <ARTIFACT-ID> [--force]");
|
|
2790
|
+
const result = locks.release(scrumDir, id, { force: args.includes("--force") });
|
|
2791
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2792
|
+
if (result.status === "denied") process.exitCode = 1;
|
|
2793
|
+
} else if (action === "--reap" || action === "reap") {
|
|
2794
|
+
console.log(JSON.stringify(locks.reapExpired(scrumDir), null, 2));
|
|
2795
|
+
} else {
|
|
2796
|
+
console.log("Usage: scrumrun lock <acquire|release|status|reap> [ARTIFACT-ID] [--note \"...\"] [--ttl <minutes>] [--force]");
|
|
2797
|
+
}
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
console.error(`lock failed: ${error.message}`);
|
|
2800
|
+
process.exitCode = 1;
|
|
2801
|
+
}
|
|
2753
2802
|
} else if (command === "view") {
|
|
2754
2803
|
(async () => {
|
|
2755
2804
|
try {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
8
|
+
|
|
9
|
+
function locksDir(scrumDir) {
|
|
10
|
+
return path.join(scrumDir, ".locks");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function lockFile(scrumDir, artifactId) {
|
|
14
|
+
if (!/^[A-Z]+-[a-z0-9]+$/i.test(String(artifactId || ""))) {
|
|
15
|
+
throw new Error(`Invalid artifact id for lock: ${artifactId}`);
|
|
16
|
+
}
|
|
17
|
+
return path.join(locksDir(scrumDir), `${artifactId}.lock`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function currentOwner() {
|
|
21
|
+
return process.env.SCRUMRUN_AGENT
|
|
22
|
+
|| process.env.USER
|
|
23
|
+
|| process.env.USERNAME
|
|
24
|
+
|| os.userInfo().username
|
|
25
|
+
|| "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readLock(scrumDir, artifactId) {
|
|
29
|
+
const file = lockFile(scrumDir, artifactId);
|
|
30
|
+
if (!fs.existsSync(file)) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
33
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
34
|
+
return { ...parsed, file, id: artifactId };
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isExpired(lock, now = Date.now()) {
|
|
39
|
+
if (!lock || !lock.expires_at) return true;
|
|
40
|
+
return Date.parse(lock.expires_at) <= now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function acquire(scrumDir, artifactId, { owner = null, ttlMs = DEFAULT_TTL_MS, note = null } = {}) {
|
|
44
|
+
fs.mkdirSync(locksDir(scrumDir), { recursive: true });
|
|
45
|
+
const existing = readLock(scrumDir, artifactId);
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const nextOwner = owner || currentOwner();
|
|
48
|
+
if (existing && !isExpired(existing, now) && existing.owner !== nextOwner) {
|
|
49
|
+
return {
|
|
50
|
+
status: "conflict",
|
|
51
|
+
lock: existing,
|
|
52
|
+
message: `${artifactId} is locked by ${existing.owner} until ${existing.expires_at}.`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const payload = {
|
|
56
|
+
id: artifactId,
|
|
57
|
+
owner: nextOwner,
|
|
58
|
+
acquired_at: new Date(now).toISOString(),
|
|
59
|
+
expires_at: new Date(now + ttlMs).toISOString(),
|
|
60
|
+
note: note || null
|
|
61
|
+
};
|
|
62
|
+
fs.writeFileSync(lockFile(scrumDir, artifactId), JSON.stringify(payload, null, 2) + "\n");
|
|
63
|
+
return { status: "acquired", lock: payload };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function release(scrumDir, artifactId, { owner = null, force = false } = {}) {
|
|
67
|
+
const existing = readLock(scrumDir, artifactId);
|
|
68
|
+
if (!existing) return { status: "not-locked" };
|
|
69
|
+
const requester = owner || currentOwner();
|
|
70
|
+
if (!force && existing.owner !== requester && !isExpired(existing)) {
|
|
71
|
+
return {
|
|
72
|
+
status: "denied",
|
|
73
|
+
lock: existing,
|
|
74
|
+
message: `${artifactId} is locked by ${existing.owner}. Use --force to override.`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
try { fs.unlinkSync(existing.file); } catch { /* already gone */ }
|
|
78
|
+
return { status: "released", lock: existing };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function status(scrumDir, artifactId) {
|
|
82
|
+
if (artifactId) {
|
|
83
|
+
const lock = readLock(scrumDir, artifactId);
|
|
84
|
+
if (!lock) return { status: "not-locked", id: artifactId };
|
|
85
|
+
return { status: isExpired(lock) ? "expired" : "locked", lock };
|
|
86
|
+
}
|
|
87
|
+
const dir = locksDir(scrumDir);
|
|
88
|
+
if (!fs.existsSync(dir)) return { status: "empty", locks: [] };
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const locks = fs.readdirSync(dir)
|
|
91
|
+
.filter((name) => name.endsWith(".lock"))
|
|
92
|
+
.map((name) => {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
95
|
+
return { ...parsed, expired: isExpired(parsed, now) };
|
|
96
|
+
} catch { return null; }
|
|
97
|
+
})
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
return { status: "listed", locks };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reapExpired(scrumDir) {
|
|
103
|
+
const dir = locksDir(scrumDir);
|
|
104
|
+
if (!fs.existsSync(dir)) return { removed: 0 };
|
|
105
|
+
let removed = 0;
|
|
106
|
+
for (const name of fs.readdirSync(dir)) {
|
|
107
|
+
if (!name.endsWith(".lock")) continue;
|
|
108
|
+
const file = path.join(dir, name);
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
111
|
+
if (isExpired(parsed)) { fs.unlinkSync(file); removed += 1; }
|
|
112
|
+
} catch { /* skip malformed */ }
|
|
113
|
+
}
|
|
114
|
+
return { removed };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { acquire, release, status, readLock, isExpired, reapExpired, currentOwner, DEFAULT_TTL_MS };
|
package/lib/team/sync.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
function git(cwd, args) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return { error: String(error && error.message || error), stderr: (error && error.stderr && error.stderr.toString()) || "" };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRepo(cwd) {
|
|
16
|
+
const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
17
|
+
return typeof inside === "string" && inside === "true";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function detectRemote(cwd) {
|
|
21
|
+
const remotes = git(cwd, ["remote"]);
|
|
22
|
+
if (typeof remotes !== "string" || !remotes) return null;
|
|
23
|
+
const list = remotes.split(/\r?\n/).filter(Boolean);
|
|
24
|
+
if (list.includes("origin")) return "origin";
|
|
25
|
+
return list[0] || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function currentBranch(cwd) {
|
|
29
|
+
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
30
|
+
return typeof branch === "string" ? branch : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasUncommittedChanges(cwd) {
|
|
34
|
+
const status = git(cwd, ["status", "--porcelain"]);
|
|
35
|
+
return typeof status === "string" && status.length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sync(projectRoot, { autoRepair = false, apply = false, logger = console } = {}) {
|
|
39
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
40
|
+
if (!fs.existsSync(scrumDir)) {
|
|
41
|
+
return { status: "no-project", message: "No .scrumrun/ directory. Run `scrumrun init` first." };
|
|
42
|
+
}
|
|
43
|
+
if (!isRepo(projectRoot)) {
|
|
44
|
+
return { status: "no-git", message: "Not a git repository. Sync requires git for team collaboration." };
|
|
45
|
+
}
|
|
46
|
+
const remote = detectRemote(projectRoot);
|
|
47
|
+
if (!remote) {
|
|
48
|
+
return { status: "no-remote", message: "No git remote configured. `git remote add origin <url>` first." };
|
|
49
|
+
}
|
|
50
|
+
const branch = currentBranch(projectRoot);
|
|
51
|
+
if (!branch || branch === "HEAD") {
|
|
52
|
+
return { status: "detached", message: "Detached HEAD state. Check out a branch first." };
|
|
53
|
+
}
|
|
54
|
+
if (hasUncommittedChanges(projectRoot)) {
|
|
55
|
+
return {
|
|
56
|
+
status: "dirty",
|
|
57
|
+
message: "Working tree has uncommitted changes. Commit or stash before sync.",
|
|
58
|
+
hint: "git status # see what's dirty"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
logger.log(`sync: fetching ${remote}...`);
|
|
63
|
+
const fetch = git(projectRoot, ["fetch", remote, branch]);
|
|
64
|
+
if (typeof fetch !== "string") {
|
|
65
|
+
return { status: "fetch-failed", message: fetch.error, stderr: fetch.stderr };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const behind = git(projectRoot, ["rev-list", "--count", `HEAD..${remote}/${branch}`]);
|
|
69
|
+
const ahead = git(projectRoot, ["rev-list", "--count", `${remote}/${branch}..HEAD`]);
|
|
70
|
+
const behindCount = typeof behind === "string" ? Number(behind) : 0;
|
|
71
|
+
const aheadCount = typeof ahead === "string" ? Number(ahead) : 0;
|
|
72
|
+
|
|
73
|
+
if (behindCount === 0) {
|
|
74
|
+
logger.log(`sync: already up to date with ${remote}/${branch} (ahead ${aheadCount}).`);
|
|
75
|
+
return { status: "up-to-date", behind: 0, ahead: aheadCount };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logger.log(`sync: rebasing onto ${remote}/${branch} (behind ${behindCount})...`);
|
|
79
|
+
const rebase = git(projectRoot, ["pull", "--rebase", remote, branch]);
|
|
80
|
+
if (typeof rebase !== "string") {
|
|
81
|
+
return {
|
|
82
|
+
status: "rebase-conflict",
|
|
83
|
+
message: "Rebase failed — likely a conflict in .scrumrun/. Resolve with git tools, then re-run.",
|
|
84
|
+
stderr: rebase.stderr
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = { status: "synced", behind: behindCount, ahead: aheadCount };
|
|
89
|
+
|
|
90
|
+
if (autoRepair) {
|
|
91
|
+
logger.log("sync: running repair...");
|
|
92
|
+
try {
|
|
93
|
+
const { repair } = require("../commands/repair");
|
|
94
|
+
const rep = repair(scrumDir, { apply, recoverOrphanTasks: false });
|
|
95
|
+
result.repair = {
|
|
96
|
+
entries: (rep.plan && rep.plan.entries && rep.plan.entries.length) || 0,
|
|
97
|
+
applied: apply
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
result.repair_error = error.message;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const { refreshState } = require("../runtime/orchestrator");
|
|
106
|
+
refreshState(scrumDir);
|
|
107
|
+
result.state_refreshed = true;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
result.state_refresh_error = error.message;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { sync, isRepo, detectRemote, currentBranch, hasUncommittedChanges };
|
package/package.json
CHANGED
|
@@ -29,17 +29,41 @@
|
|
|
29
29
|
main { padding: 16px 24px; display: grid; gap: 16px; }
|
|
30
30
|
section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
|
|
31
31
|
section h2 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; color: var(--muted); letter-spacing: .04em; font-weight: 600; }
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
.
|
|
38
|
-
.
|
|
32
|
+
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
|
33
|
+
.toolbar .group { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
34
|
+
.toolbar button { background: transparent; border: 0; color: var(--muted); padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
35
|
+
.toolbar button.active { background: var(--border); color: var(--fg); }
|
|
36
|
+
.toolbar button:hover { color: var(--fg); }
|
|
37
|
+
.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; align-items: start; }
|
|
38
|
+
.kanban.compact { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
|
|
39
|
+
.col { min-width: 0; }
|
|
40
|
+
.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
|
+
.col h3 .count { background: var(--border); color: var(--fg); padding: 1px 6px; border-radius: 10px; font-family: var(--mono); font-size: 10px; }
|
|
42
|
+
.col-body { max-height: 70vh; overflow-y: auto; padding-right: 4px; }
|
|
43
|
+
.col-body::-webkit-scrollbar { width: 6px; }
|
|
44
|
+
.col-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
45
|
+
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s; }
|
|
46
|
+
.card:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
|
47
|
+
.card .id { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: .04em; }
|
|
48
|
+
.card .title { font-size: 13.5px; font-weight: 500; margin-top: 3px; line-height: 1.35; color: var(--fg); }
|
|
49
|
+
.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
|
+
body.list-mode .kanban { display: block; }
|
|
54
|
+
body.list-mode .col { margin-bottom: 20px; }
|
|
55
|
+
body.list-mode .col-body { max-height: none; }
|
|
56
|
+
body.list-mode .card { display: grid; grid-template-columns: 100px 1fr auto; gap: 12px; align-items: center; padding: 8px 14px; }
|
|
57
|
+
body.list-mode .card .id { margin: 0; }
|
|
58
|
+
body.list-mode .card .title { margin: 0; }
|
|
59
|
+
body.list-mode .card .meta { margin: 0; justify-content: flex-end; }
|
|
60
|
+
body.readonly .card { cursor: default; }
|
|
61
|
+
body.readonly .card:hover { transform: none; }
|
|
39
62
|
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--border); font-size: 10px; font-family: var(--mono); }
|
|
40
63
|
.badge.type-fix { color: var(--err); }
|
|
41
64
|
.badge.type-feature { color: var(--accent); }
|
|
42
65
|
.badge.type-docs { color: var(--muted); }
|
|
66
|
+
.badge.assignee { color: var(--accent); background: color-mix(in oklab, var(--accent) 15%, transparent); }
|
|
43
67
|
.search { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font: inherit; width: 240px; }
|
|
44
68
|
.empty { color: var(--muted); font-style: italic; padding: 8px 0; }
|
|
45
69
|
.err { color: var(--err); font-family: var(--mono); font-size: 12px; }
|
|
@@ -47,11 +71,37 @@
|
|
|
47
71
|
#detail.open { transform: translateX(0); }
|
|
48
72
|
#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); }
|
|
49
73
|
#detail .close { float: right; background: none; border: 0; color: var(--muted); font-size: 20px; cursor: pointer; }
|
|
50
|
-
.
|
|
74
|
+
.md { line-height: 1.55; font-size: 13.5px; color: var(--fg); }
|
|
75
|
+
.md h1, .md h2, .md h3, .md h4 { margin: 18px 0 8px; font-weight: 600; }
|
|
76
|
+
.md h1 { font-size: 20px; }
|
|
77
|
+
.md h2 { font-size: 16px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
|
|
78
|
+
.md h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
79
|
+
.md h4 { font-size: 13px; color: var(--muted); }
|
|
80
|
+
.md p { margin: 8px 0; }
|
|
81
|
+
.md ul, .md ol { margin: 8px 0; padding-left: 22px; }
|
|
82
|
+
.md li { margin: 4px 0; }
|
|
83
|
+
.md code { font-family: var(--mono); font-size: 12px; background: var(--bg); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
|
|
84
|
+
.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; }
|
|
85
|
+
.md pre.md-code code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
86
|
+
.md a { color: var(--accent); text-decoration: none; }
|
|
87
|
+
.md a:hover { text-decoration: underline; }
|
|
88
|
+
.md strong { color: var(--fg); font-weight: 600; }
|
|
89
|
+
.md em { font-style: italic; }
|
|
90
|
+
.md hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
91
|
+
.md blockquote { border-left: 3px solid var(--border); padding: 4px 12px; margin: 10px 0; color: var(--muted); }
|
|
92
|
+
.md table { border-collapse: collapse; margin: 10px 0; font-size: 12.5px; }
|
|
93
|
+
.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; }
|
|
94
|
+
.md th { background: var(--bg); font-weight: 600; }
|
|
95
|
+
.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; }
|
|
96
|
+
.md-meta .k { font-family: var(--mono); color: var(--muted); font-size: 11px; }
|
|
97
|
+
.md-meta .v { font-family: var(--mono); font-size: 11.5px; word-break: break-all; }
|
|
98
|
+
.list-item { padding: 8px 0; border-bottom: 1px dotted var(--border); cursor: pointer; display: flex; align-items: baseline; gap: 12px; }
|
|
51
99
|
.list-item:last-child { border-bottom: 0; }
|
|
52
|
-
.list-item:hover { color: var(--
|
|
53
|
-
.
|
|
54
|
-
.row
|
|
100
|
+
.list-item:hover .row-text { color: var(--fg); }
|
|
101
|
+
.list-item:hover .row-id { color: var(--accent); }
|
|
102
|
+
.row-id { font-family: var(--mono); font-size: 11px; color: var(--muted); min-width: 72px; flex-shrink: 0; letter-spacing: .04em; }
|
|
103
|
+
.row-text { font-size: 13px; color: var(--fg); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
104
|
+
.row-meta { font-size: 11px; color: var(--muted); flex-shrink: 0; }
|
|
55
105
|
.header-right { margin-left: auto; display: flex; gap: 12px; align-items: center; }
|
|
56
106
|
</style>
|
|
57
107
|
</head>
|
|
@@ -67,6 +117,13 @@
|
|
|
67
117
|
<section id="err-panel" style="display:none"><h2>Load error</h2><div class="err" id="err-msg"></div></section>
|
|
68
118
|
<section>
|
|
69
119
|
<h2>Tasks</h2>
|
|
120
|
+
<div class="toolbar">
|
|
121
|
+
<div class="group" role="tablist" aria-label="View mode">
|
|
122
|
+
<button data-mode="kanban" class="active">Kanban</button>
|
|
123
|
+
<button data-mode="compact">Compact</button>
|
|
124
|
+
<button data-mode="list">List</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
70
127
|
<div class="kanban" id="kanban"></div>
|
|
71
128
|
</section>
|
|
72
129
|
<section>
|
|
@@ -90,6 +147,7 @@
|
|
|
90
147
|
(() => {
|
|
91
148
|
"use strict";
|
|
92
149
|
const STATUS_ORDER = ["backlog", "running", "in_progress", "validating", "learning", "blocked", "failed", "completed"];
|
|
150
|
+
const ALWAYS_SHOW_STATUSES = ["backlog", "running", "completed"];
|
|
93
151
|
const STATUS_LABELS = {
|
|
94
152
|
backlog: "Backlog",
|
|
95
153
|
running: "Running",
|
|
@@ -159,32 +217,46 @@
|
|
|
159
217
|
if (!bucket[status]) bucket[status] = [];
|
|
160
218
|
bucket[status].push(task);
|
|
161
219
|
}
|
|
162
|
-
const ordered = STATUS_ORDER.filter((s) => bucket[s] && bucket[s].length);
|
|
220
|
+
const ordered = STATUS_ORDER.filter((s) => (bucket[s] && bucket[s].length) || ALWAYS_SHOW_STATUSES.includes(s));
|
|
163
221
|
const container = document.getElementById("kanban");
|
|
164
222
|
container.innerHTML = "";
|
|
165
|
-
if (!
|
|
223
|
+
if (!tasks.length) { container.innerHTML = '<div class="empty">no tasks found</div>'; return; }
|
|
166
224
|
for (const status of ordered) {
|
|
225
|
+
const items = bucket[status] || [];
|
|
167
226
|
const col = document.createElement("div");
|
|
168
227
|
col.className = "col";
|
|
169
|
-
col.innerHTML = `<h3>${STATUS_LABELS[status] || status}
|
|
170
|
-
|
|
228
|
+
col.innerHTML = `<h3>${STATUS_LABELS[status] || status} <span class="count">${items.length}</span></h3>`;
|
|
229
|
+
const body = document.createElement("div");
|
|
230
|
+
body.className = "col-body";
|
|
231
|
+
col.appendChild(body);
|
|
232
|
+
if (!items.length) {
|
|
233
|
+
const empty = document.createElement("div");
|
|
234
|
+
empty.className = "empty";
|
|
235
|
+
empty.style.padding = "6px 0";
|
|
236
|
+
empty.textContent = "—";
|
|
237
|
+
body.appendChild(empty);
|
|
238
|
+
}
|
|
239
|
+
for (const task of items) {
|
|
171
240
|
const title = extractHeading(task.body);
|
|
172
241
|
const type = task.record.type || "task";
|
|
173
242
|
const branch = task.record.branch;
|
|
174
243
|
const card = document.createElement("div");
|
|
175
244
|
card.className = "card";
|
|
176
245
|
card.dataset.id = task.record.id;
|
|
246
|
+
const assignee = task.record.assignee;
|
|
247
|
+
card.dataset.assignee = assignee || "";
|
|
177
248
|
card.innerHTML = `
|
|
178
249
|
<div class="id">${task.record.id}</div>
|
|
179
250
|
<div class="title"></div>
|
|
180
251
|
<div class="meta">
|
|
181
252
|
<span class="badge type-${type}">${type}</span>
|
|
253
|
+
${assignee ? `<span class="badge assignee">@${assignee}</span>` : ""}
|
|
182
254
|
${branch ? `<span class="badge">${branch}</span>` : ""}
|
|
183
255
|
${task.record.sprint ? `<span class="badge">${task.record.sprint}</span>` : ""}
|
|
184
256
|
</div>`;
|
|
185
257
|
card.querySelector(".title").textContent = title;
|
|
186
258
|
card.addEventListener("click", () => openDetail(task));
|
|
187
|
-
|
|
259
|
+
body.appendChild(card);
|
|
188
260
|
}
|
|
189
261
|
container.appendChild(col);
|
|
190
262
|
}
|
|
@@ -198,7 +270,7 @@
|
|
|
198
270
|
const row = document.createElement("div");
|
|
199
271
|
row.className = "list-item";
|
|
200
272
|
row.innerHTML = format(item);
|
|
201
|
-
row.addEventListener("click", () => openDetail(item));
|
|
273
|
+
if (item && item.record) row.addEventListener("click", () => openDetail(item));
|
|
202
274
|
el.appendChild(row);
|
|
203
275
|
}
|
|
204
276
|
}
|
|
@@ -211,38 +283,166 @@
|
|
|
211
283
|
rules.push({ id: match[1], title: match[2] });
|
|
212
284
|
}
|
|
213
285
|
el.innerHTML = rules.length
|
|
214
|
-
? rules.map((r) => `<div class="
|
|
286
|
+
? rules.map((r) => `<div class="list-item"><span class="row-id">${r.id}</span><span class="row-text">${escape(r.title)}</span></div>`).join("")
|
|
215
287
|
: '<div class="empty">no guardrails declared</div>';
|
|
216
288
|
}
|
|
217
289
|
|
|
290
|
+
function escape(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
291
|
+
|
|
292
|
+
function renderInline(text) {
|
|
293
|
+
let s = escape(text);
|
|
294
|
+
// code inline first (so we don't process markdown inside)
|
|
295
|
+
s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
|
|
296
|
+
// links [text](url)
|
|
297
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${href}" target="_blank" rel="noopener">${label}</a>`);
|
|
298
|
+
// bold and italic
|
|
299
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
300
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
|
|
301
|
+
// auto-link bare TASK-NNN / RUN-NNN / etc — do it last, and skip if already inside a tag
|
|
302
|
+
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>");
|
|
303
|
+
return s;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderMarkdown(source) {
|
|
307
|
+
const lines = String(source || "").split(/\r?\n/);
|
|
308
|
+
const out = [];
|
|
309
|
+
let i = 0;
|
|
310
|
+
let inCode = false;
|
|
311
|
+
let codeBuf = [];
|
|
312
|
+
const flushCode = () => {
|
|
313
|
+
out.push(`<pre class="md-code"><code>${escape(codeBuf.join("\n"))}</code></pre>`);
|
|
314
|
+
codeBuf = [];
|
|
315
|
+
};
|
|
316
|
+
while (i < lines.length) {
|
|
317
|
+
const line = lines[i];
|
|
318
|
+
if (/^```/.test(line)) {
|
|
319
|
+
if (inCode) { flushCode(); inCode = false; }
|
|
320
|
+
else { inCode = true; }
|
|
321
|
+
i += 1; continue;
|
|
322
|
+
}
|
|
323
|
+
if (inCode) { codeBuf.push(line); i += 1; continue; }
|
|
324
|
+
if (/^#{1,4}\s/.test(line)) {
|
|
325
|
+
const level = line.match(/^(#{1,4})/)[1].length;
|
|
326
|
+
const text = line.replace(/^#{1,4}\s+/, "");
|
|
327
|
+
out.push(`<h${level}>${renderInline(text)}</h${level}>`);
|
|
328
|
+
i += 1; continue;
|
|
329
|
+
}
|
|
330
|
+
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) {
|
|
331
|
+
out.push("<hr />");
|
|
332
|
+
i += 1; continue;
|
|
333
|
+
}
|
|
334
|
+
if (/^\s*>\s?/.test(line)) {
|
|
335
|
+
const chunk = [];
|
|
336
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
337
|
+
chunk.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
338
|
+
i += 1;
|
|
339
|
+
}
|
|
340
|
+
out.push(`<blockquote>${renderInline(chunk.join(" "))}</blockquote>`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
344
|
+
const items = [];
|
|
345
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
346
|
+
items.push(`<li>${renderInline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>`);
|
|
347
|
+
i += 1;
|
|
348
|
+
}
|
|
349
|
+
out.push(`<ul>${items.join("")}</ul>`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
353
|
+
const items = [];
|
|
354
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
|
355
|
+
items.push(`<li>${renderInline(lines[i].replace(/^\s*\d+\.\s+/, ""))}</li>`);
|
|
356
|
+
i += 1;
|
|
357
|
+
}
|
|
358
|
+
out.push(`<ol>${items.join("")}</ol>`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!line.trim()) { i += 1; continue; }
|
|
362
|
+
// paragraph — accumulate until blank line
|
|
363
|
+
const para = [line];
|
|
364
|
+
i += 1;
|
|
365
|
+
while (i < lines.length && lines[i].trim() && !/^(#{1,4}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>|-{3,}\s*$)/.test(lines[i])) {
|
|
366
|
+
para.push(lines[i]);
|
|
367
|
+
i += 1;
|
|
368
|
+
}
|
|
369
|
+
out.push(`<p>${renderInline(para.join(" "))}</p>`);
|
|
370
|
+
}
|
|
371
|
+
if (inCode && codeBuf.length) flushCode();
|
|
372
|
+
return out.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderMeta(record) {
|
|
376
|
+
const order = ["id", "kind", "status", "type", "created", "updated", "feature", "sprint", "branch", "assignee", "completed_via", "method"];
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const rows = [];
|
|
379
|
+
for (const key of order) {
|
|
380
|
+
if (record[key] === undefined || record[key] === null || record[key] === "") continue;
|
|
381
|
+
seen.add(key);
|
|
382
|
+
rows.push(`<span class="k">${escape(key)}</span><span class="v">${escape(String(record[key]))}</span>`);
|
|
383
|
+
}
|
|
384
|
+
for (const [key, value] of Object.entries(record)) {
|
|
385
|
+
if (seen.has(key) || value === undefined || value === null || value === "") continue;
|
|
386
|
+
rows.push(`<span class="k">${escape(key)}</span><span class="v">${escape(String(value))}</span>`);
|
|
387
|
+
}
|
|
388
|
+
return `<div class="md-meta">${rows.join("")}</div>`;
|
|
389
|
+
}
|
|
390
|
+
|
|
218
391
|
function openDetail(artifact) {
|
|
219
392
|
const body = document.getElementById("detail-body");
|
|
220
393
|
const record = artifact.record || {};
|
|
221
|
-
const
|
|
394
|
+
const title = extractHeading(artifact.body || "");
|
|
222
395
|
body.innerHTML = `
|
|
223
|
-
<h2 style="margin-top:0;font-size:
|
|
224
|
-
<div class="sub" style="margin-bottom:
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
<div class="sub"><a href="${artifact.file || "#"}" target="_blank" rel="noopener">open raw file</a></div>`;
|
|
396
|
+
<h2 style="margin-top:0;font-size:15px">${escape(record.id || "(unknown)")}</h2>
|
|
397
|
+
<div class="sub" style="margin-bottom:12px">${escape(title)}</div>
|
|
398
|
+
${renderMeta(record)}
|
|
399
|
+
<div class="md">${renderMarkdown(artifact.body || "")}</div>
|
|
400
|
+
<div class="sub" style="margin-top:14px"><a href="${artifact.file || "#"}" target="_blank" rel="noopener">open raw file</a></div>`;
|
|
228
401
|
document.getElementById("detail").classList.add("open");
|
|
229
402
|
document.getElementById("detail").setAttribute("aria-hidden", "false");
|
|
230
403
|
}
|
|
231
404
|
|
|
232
|
-
function escape(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
233
|
-
|
|
234
405
|
document.getElementById("detail-close").addEventListener("click", () => {
|
|
235
406
|
document.getElementById("detail").classList.remove("open");
|
|
236
407
|
document.getElementById("detail").setAttribute("aria-hidden", "true");
|
|
237
408
|
});
|
|
238
409
|
|
|
239
|
-
|
|
240
|
-
|
|
410
|
+
const params = new URLSearchParams(window.location.search);
|
|
411
|
+
const assigneeFilter = (params.get("assignee") || "").trim().toLowerCase();
|
|
412
|
+
const readonly = params.get("readonly") === "1";
|
|
413
|
+
|
|
414
|
+
function applyFilters() {
|
|
415
|
+
const q = (document.getElementById("search").value || "").trim().toLowerCase();
|
|
241
416
|
for (const card of document.querySelectorAll(".card")) {
|
|
242
417
|
const text = card.textContent.toLowerCase();
|
|
243
|
-
card.
|
|
418
|
+
const cardAssignee = (card.dataset.assignee || "").toLowerCase();
|
|
419
|
+
const matchesSearch = !q || text.includes(q);
|
|
420
|
+
const matchesAssignee = !assigneeFilter || cardAssignee === assigneeFilter;
|
|
421
|
+
card.style.display = matchesSearch && matchesAssignee ? "" : "none";
|
|
244
422
|
}
|
|
245
|
-
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
document.getElementById("search").addEventListener("input", applyFilters);
|
|
426
|
+
|
|
427
|
+
const kanbanEl = document.getElementById("kanban");
|
|
428
|
+
const savedMode = localStorage.getItem("scrumrun.view.mode") || "kanban";
|
|
429
|
+
function setMode(mode) {
|
|
430
|
+
kanbanEl.classList.toggle("compact", mode === "compact");
|
|
431
|
+
document.body.classList.toggle("list-mode", mode === "list");
|
|
432
|
+
for (const button of document.querySelectorAll(".toolbar button")) {
|
|
433
|
+
button.classList.toggle("active", button.dataset.mode === mode);
|
|
434
|
+
}
|
|
435
|
+
localStorage.setItem("scrumrun.view.mode", mode);
|
|
436
|
+
}
|
|
437
|
+
for (const button of document.querySelectorAll(".toolbar button")) {
|
|
438
|
+
button.addEventListener("click", () => setMode(button.dataset.mode));
|
|
439
|
+
}
|
|
440
|
+
setMode(savedMode);
|
|
441
|
+
if (readonly) document.body.classList.add("readonly");
|
|
442
|
+
if (assigneeFilter) {
|
|
443
|
+
const label = document.getElementById("project-label");
|
|
444
|
+
label.textContent = label.textContent + ` · filter: @${assigneeFilter}`;
|
|
445
|
+
}
|
|
246
446
|
|
|
247
447
|
async function boot() {
|
|
248
448
|
const label = document.getElementById("project-label");
|
|
@@ -266,8 +466,9 @@
|
|
|
266
466
|
return;
|
|
267
467
|
}
|
|
268
468
|
renderKanban(tasks);
|
|
269
|
-
|
|
270
|
-
renderList("
|
|
469
|
+
applyFilters();
|
|
470
|
+
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>` : ""}`);
|
|
471
|
+
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>` : ""}`);
|
|
271
472
|
renderGuardrailsFromText(guardrailsText);
|
|
272
473
|
}
|
|
273
474
|
|