scrumrun 4.1.2 → 4.1.4

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,18 @@ All notable changes follow Semantic Versioning.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 4.1.4 - 2026-09-22
8
+
9
+ ### Added
10
+
11
+ - **`scrumrun view`.** New command that spins up a temporary HTTP server on `127.0.0.1:8080` (falls back to the next free port up to 8100), serves `.scrumrun/` as static files with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`, and opens the browser at `view.html`. Removes the friction of `file://` fetch restrictions in Brave/Chrome/Safari and eliminates the need for `python3 -m http.server`. Flags: `--port <n>`, `--host <ip>` (default `127.0.0.1` — never bind to `0.0.0.0` unless explicit), `--no-open` (headless). Cross-platform browser open via `open` (macOS), `xdg-open` (Linux), `start` (Windows). Ctrl+C to stop.
12
+
13
+ ## 4.1.3 - 2026-09-22
14
+
15
+ ### Fixed
16
+
17
+ - **`parseFrontmatter` handles multi-line arrays and YAML block lists.** The inline `allow_secrets_in: [a,\nb,\nc]` and the block form `allow_secrets_in:\n - a\n - b` used to be parsed as a single truncated string, silently disabling the whitelist. The parser now (a) keeps reading until the closing `]` for multi-line inline arrays, and (b) recognizes `- item` block lists as an array value. Existing single-line inline arrays continue to work.
18
+
7
19
  ## 4.1.2 - 2026-09-22
8
20
 
9
21
  ### Fixed
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.2` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `4.1.4` · **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,6 +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 8080] [--host 127.0.0.1] [--no-open]
67
68
  scrumrun core [--path|--prompt]
68
69
  scrumrun commands
69
70
  scrumrun migrate --to 2 --dry-run
@@ -2749,6 +2750,24 @@ if (!command || command === "--help" || command === "-h") {
2749
2750
  } else {
2750
2751
  initProject({ force, mode: shared ? "shared" : "local", agentHint: shared || !noAgentHint, lean });
2751
2752
  }
2753
+ } else if (command === "view") {
2754
+ (async () => {
2755
+ try {
2756
+ const { view } = require(path.join(root, "lib", "view", "server"));
2757
+ const portArg = args.indexOf("--port");
2758
+ const hostArg = args.indexOf("--host");
2759
+ const port = portArg !== -1 && args[portArg + 1] ? Number(args[portArg + 1]) : 8080;
2760
+ const host = hostArg !== -1 && args[hostArg + 1] ? args[hostArg + 1] : "127.0.0.1";
2761
+ const autoOpen = !args.includes("--no-open");
2762
+ const { server } = await view(process.cwd(), { port, host, autoOpen });
2763
+ const stop = () => { server.close(() => process.exit(0)); };
2764
+ process.on("SIGINT", stop);
2765
+ process.on("SIGTERM", stop);
2766
+ } catch (error) {
2767
+ console.error(`view failed: ${error.message}`);
2768
+ process.exitCode = 1;
2769
+ }
2770
+ })();
2752
2771
  } else if (command === "repair") {
2753
2772
  const scrumDir = path.join(process.cwd(), ".scrumrun");
2754
2773
  try {
@@ -41,12 +41,42 @@ function parseFrontmatter(text) {
41
41
  if (!match) return { explicit: {}, raw: "" };
42
42
  const body = match[1];
43
43
  const explicit = {};
44
- for (const line of body.split(/\r?\n/)) {
45
- if (!line.trim() || line.trim().startsWith("#")) continue;
44
+ const rawLines = body.split(/\r?\n/);
45
+ let index = 0;
46
+ while (index < rawLines.length) {
47
+ const line = rawLines[index];
48
+ if (!line.trim() || line.trim().startsWith("#")) { index += 1; continue; }
46
49
  const kv = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
47
- if (!kv) continue;
50
+ if (!kv) { index += 1; continue; }
48
51
  const key = kv[1].trim();
49
52
  let value = kv[2].trim();
53
+
54
+ // Inline array that spans multiple lines: keep reading until we see the closing `]`.
55
+ if (value.startsWith("[") && !value.endsWith("]")) {
56
+ let acc = value;
57
+ let cursor = index + 1;
58
+ while (cursor < rawLines.length) {
59
+ acc += " " + rawLines[cursor].trim();
60
+ if (rawLines[cursor].trim().endsWith("]")) { break; }
61
+ cursor += 1;
62
+ }
63
+ value = acc;
64
+ index = cursor;
65
+ }
66
+
67
+ // YAML block list: subsequent lines start with `- item`. Consume while indented under this key.
68
+ if (value === "" && index + 1 < rawLines.length && /^\s*-\s+/.test(rawLines[index + 1])) {
69
+ const items = [];
70
+ let cursor = index + 1;
71
+ while (cursor < rawLines.length && /^\s*-\s+/.test(rawLines[cursor])) {
72
+ items.push(rawLines[cursor].replace(/^\s*-\s+/, "").trim().replace(/^['"]|['"]$/g, ""));
73
+ cursor += 1;
74
+ }
75
+ explicit[key] = items.filter(Boolean);
76
+ index = cursor;
77
+ continue;
78
+ }
79
+
50
80
  if (value.startsWith("[") && value.endsWith("]")) {
51
81
  value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
52
82
  } else if (/^['"].*['"]$/.test(value)) {
@@ -55,6 +85,7 @@ function parseFrontmatter(text) {
55
85
  value = value === "true";
56
86
  }
57
87
  explicit[key] = value;
88
+ index += 1;
58
89
  }
59
90
  return { explicit, raw: body };
60
91
  }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const http = require("node:http");
5
+ const path = require("node:path");
6
+ const { execFile, spawn } = require("node:child_process");
7
+
8
+ const MIME = Object.freeze({
9
+ ".html": "text/html; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".js": "application/javascript; charset=utf-8",
12
+ ".json": "application/json; charset=utf-8",
13
+ ".md": "text/markdown; charset=utf-8",
14
+ ".jsonl": "application/x-ndjson; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".png": "image/png",
17
+ ".ico": "image/x-icon",
18
+ ".txt": "text/plain; charset=utf-8"
19
+ });
20
+
21
+ function contentType(filePath) {
22
+ return MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream";
23
+ }
24
+
25
+ function safeResolve(root, urlPath) {
26
+ const decoded = decodeURIComponent(urlPath.split("?")[0]).replace(/^\/+/, "");
27
+ const target = path.resolve(root, decoded || "view.html");
28
+ const rootResolved = path.resolve(root);
29
+ if (target !== rootResolved && !target.startsWith(rootResolved + path.sep)) return null;
30
+ return target;
31
+ }
32
+
33
+ function requestHandler(root) {
34
+ return (req, res) => {
35
+ if (req.method !== "GET" && req.method !== "HEAD") {
36
+ res.writeHead(405, { "Allow": "GET, HEAD" });
37
+ return res.end("Method Not Allowed");
38
+ }
39
+ const target = safeResolve(root, req.url || "/");
40
+ if (!target) {
41
+ res.writeHead(400);
42
+ return res.end("Bad path");
43
+ }
44
+ let filePath = target;
45
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
46
+ filePath = path.join(filePath, "view.html");
47
+ }
48
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
49
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
50
+ return res.end("Not found");
51
+ }
52
+ res.writeHead(200, {
53
+ "Content-Type": contentType(filePath),
54
+ "Cache-Control": "no-store",
55
+ "X-Content-Type-Options": "nosniff"
56
+ });
57
+ if (req.method === "HEAD") return res.end();
58
+ fs.createReadStream(filePath).pipe(res);
59
+ };
60
+ }
61
+
62
+ function listen(root, { host = "127.0.0.1", port = 8080 } = {}) {
63
+ return new Promise((resolve, reject) => {
64
+ const server = http.createServer(requestHandler(root));
65
+ let attempts = 0;
66
+ const tryPort = (p) => {
67
+ server.once("error", (err) => {
68
+ if (err.code === "EADDRINUSE" && attempts < 20) {
69
+ attempts += 1;
70
+ setImmediate(() => tryPort(p + 1));
71
+ } else {
72
+ reject(err);
73
+ }
74
+ });
75
+ server.listen(p, host, () => {
76
+ const addr = server.address();
77
+ resolve({ server, port: (addr && addr.port) || p, host });
78
+ });
79
+ };
80
+ tryPort(port);
81
+ });
82
+ }
83
+
84
+ function openInBrowser(url) {
85
+ const platform = process.platform;
86
+ if (platform === "darwin") return spawn("open", [url], { stdio: "ignore", detached: true }).unref();
87
+ if (platform === "win32") return spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
88
+ return spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
89
+ }
90
+
91
+ async function view(projectRoot, { port = 8080, host = "127.0.0.1", autoOpen = true, logger = console } = {}) {
92
+ const root = path.join(projectRoot, ".scrumrun");
93
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
94
+ throw new Error(`ScrumRun project not found at ${root}. Run \`scrumrun init\` first.`);
95
+ }
96
+ const viewFile = path.join(root, "view.html");
97
+ if (!fs.existsSync(viewFile)) {
98
+ throw new Error(`view.html is missing at ${viewFile}. Run \`scrumrun update --project\` to copy the latest template.`);
99
+ }
100
+ const { server, port: boundPort } = await listen(root, { host, port });
101
+ const url = `http://${host}:${boundPort}/view.html`;
102
+ logger.log(`ScrumRun view running at ${url}`);
103
+ logger.log("Press Ctrl+C to stop.");
104
+ if (autoOpen) {
105
+ try { openInBrowser(url); } catch { /* non-fatal */ }
106
+ }
107
+ return { server, url, port: boundPort, host };
108
+ }
109
+
110
+ module.exports = { view, listen, requestHandler, safeResolve, openInBrowser };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "4.1.2",
3
+ "version": "4.1.4",
4
4
  "description": "Markdown-first Agile memory and guardrails for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",