scrumrun 4.1.4 → 4.2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes follow Semantic Versioning.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 4.2.0 - 2026-09-22
8
+
9
+ ### Added
10
+
11
+ - **`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
+ - **`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.
14
+
7
15
  ## 4.1.4 - 2026-09-22
8
16
 
9
17
  ### 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.4` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `4.2.0` · **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 };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "4.1.4",
3
+ "version": "4.2.0",
4
4
  "description": "Markdown-first Agile memory and guardrails for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",
@@ -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
- .kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
33
- .col h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: .04em; }
34
- .card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; cursor: pointer; transition: border-color .15s; }
35
- .card:hover { border-color: var(--accent); }
36
- .card .id { font-family: var(--mono); font-size: 11px; color: var(--muted); }
37
- .card .title { font-size: 13px; margin-top: 2px; }
38
- .card .meta { font-size: 11px; color: var(--muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
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; }
@@ -67,6 +91,13 @@
67
91
  <section id="err-panel" style="display:none"><h2>Load error</h2><div class="err" id="err-msg"></div></section>
68
92
  <section>
69
93
  <h2>Tasks</h2>
94
+ <div class="toolbar">
95
+ <div class="group" role="tablist" aria-label="View mode">
96
+ <button data-mode="kanban" class="active">Kanban</button>
97
+ <button data-mode="compact">Compact</button>
98
+ <button data-mode="list">List</button>
99
+ </div>
100
+ </div>
70
101
  <div class="kanban" id="kanban"></div>
71
102
  </section>
72
103
  <section>
@@ -166,7 +197,10 @@
166
197
  for (const status of ordered) {
167
198
  const col = document.createElement("div");
168
199
  col.className = "col";
169
- col.innerHTML = `<h3>${STATUS_LABELS[status] || status} (${bucket[status].length})</h3>`;
200
+ col.innerHTML = `<h3>${STATUS_LABELS[status] || status} <span class="count">${bucket[status].length}</span></h3>`;
201
+ const body = document.createElement("div");
202
+ body.className = "col-body";
203
+ col.appendChild(body);
170
204
  for (const task of bucket[status]) {
171
205
  const title = extractHeading(task.body);
172
206
  const type = task.record.type || "task";
@@ -174,17 +208,20 @@
174
208
  const card = document.createElement("div");
175
209
  card.className = "card";
176
210
  card.dataset.id = task.record.id;
211
+ const assignee = task.record.assignee;
212
+ card.dataset.assignee = assignee || "";
177
213
  card.innerHTML = `
178
214
  <div class="id">${task.record.id}</div>
179
215
  <div class="title"></div>
180
216
  <div class="meta">
181
217
  <span class="badge type-${type}">${type}</span>
218
+ ${assignee ? `<span class="badge assignee">@${assignee}</span>` : ""}
182
219
  ${branch ? `<span class="badge">${branch}</span>` : ""}
183
220
  ${task.record.sprint ? `<span class="badge">${task.record.sprint}</span>` : ""}
184
221
  </div>`;
185
222
  card.querySelector(".title").textContent = title;
186
223
  card.addEventListener("click", () => openDetail(task));
187
- col.appendChild(card);
224
+ body.appendChild(card);
188
225
  }
189
226
  container.appendChild(col);
190
227
  }
@@ -236,13 +273,42 @@
236
273
  document.getElementById("detail").setAttribute("aria-hidden", "true");
237
274
  });
238
275
 
239
- document.getElementById("search").addEventListener("input", (event) => {
240
- const q = event.target.value.trim().toLowerCase();
276
+ const params = new URLSearchParams(window.location.search);
277
+ const assigneeFilter = (params.get("assignee") || "").trim().toLowerCase();
278
+ const readonly = params.get("readonly") === "1";
279
+
280
+ function applyFilters() {
281
+ const q = (document.getElementById("search").value || "").trim().toLowerCase();
241
282
  for (const card of document.querySelectorAll(".card")) {
242
283
  const text = card.textContent.toLowerCase();
243
- card.style.display = !q || text.includes(q) ? "" : "none";
284
+ const cardAssignee = (card.dataset.assignee || "").toLowerCase();
285
+ const matchesSearch = !q || text.includes(q);
286
+ const matchesAssignee = !assigneeFilter || cardAssignee === assigneeFilter;
287
+ card.style.display = matchesSearch && matchesAssignee ? "" : "none";
244
288
  }
245
- });
289
+ }
290
+
291
+ document.getElementById("search").addEventListener("input", applyFilters);
292
+
293
+ const kanbanEl = document.getElementById("kanban");
294
+ const savedMode = localStorage.getItem("scrumrun.view.mode") || "kanban";
295
+ function setMode(mode) {
296
+ kanbanEl.classList.toggle("compact", mode === "compact");
297
+ document.body.classList.toggle("list-mode", mode === "list");
298
+ for (const button of document.querySelectorAll(".toolbar button")) {
299
+ button.classList.toggle("active", button.dataset.mode === mode);
300
+ }
301
+ localStorage.setItem("scrumrun.view.mode", mode);
302
+ }
303
+ for (const button of document.querySelectorAll(".toolbar button")) {
304
+ button.addEventListener("click", () => setMode(button.dataset.mode));
305
+ }
306
+ setMode(savedMode);
307
+ if (readonly) document.body.classList.add("readonly");
308
+ if (assigneeFilter) {
309
+ const label = document.getElementById("project-label");
310
+ label.textContent = label.textContent + ` · filter: @${assigneeFilter}`;
311
+ }
246
312
 
247
313
  async function boot() {
248
314
  const label = document.getElementById("project-label");
@@ -266,6 +332,7 @@
266
332
  return;
267
333
  }
268
334
  renderKanban(tasks);
335
+ applyFilters();
269
336
  renderList("runs", runs.slice().reverse().slice(0, 20), (r) => `<div class="row"><span>${r.record.id}</span><span class="muted">${extractHeading(r.body)}</span></div>`);
270
337
  renderList("decisions", decisions, (d) => `<div class="row"><span>${d.record.id}</span><span class="muted">${extractHeading(d.body)}</span></div>`);
271
338
  renderGuardrailsFromText(guardrailsText);