@dansyuqri/spark-joy 0.2.1 → 0.3.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.
@@ -0,0 +1 @@
1
+ :root{--bg:#fbfaf8;--panel:#fff;--ink:#1d1d1f;--muted:#6b6b72;--line:#e4e2dd;--accent:#c2410c;--accent-ink:#fff;--add-bg:#e9f8ec;--add-ink:#116329;--del-bg:#fdeceb;--del-ink:#a40e26;--hunk-bg:#f1f0fb;--note-bg:#fff8e5;--done:#15803d;--partial:#b45309;--todo:#9ca3af;--mono:ui-monospace, "SF Mono", Menlo, Consolas, monospace;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial;--bg:#151518;--panel:#1c1c20;--ink:#ececef;--muted:#9a9aa3;--line:#2e2e34;--accent:#fb923c;--accent-ink:#1a1a1a;--add-bg:#12301c;--add-ink:#7ee2a0;--del-bg:#3a1619;--del-ink:#ff9aa5;--hunk-bg:#23233a;--note-bg:#2f2a18;--done:#4ade80;--partial:#fbbf24;--todo:#6b7280}}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code,kbd{font-family:var(--mono);font-size:.92em}kbd{border:1px solid var(--line);border-radius:4px;padding:0 4px}button{font:inherit;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer;border-radius:6px;padding:6px 12px}button:hover:not(:disabled){border-color:var(--muted)}button:disabled{opacity:.4;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}textarea{font:inherit;border:1px solid var(--line);background:var(--panel);width:100%;min-height:70px;color:var(--ink);resize:vertical;border-radius:6px;padding:8px}.muted{color:var(--muted)}.small{font-size:12px}.warn{color:var(--partial)}.pre{white-space:pre-wrap}.empty{max-width:640px;margin:80px auto;padding:0 16px}.topbar{z-index:10;background:var(--panel);border-bottom:1px solid var(--line);align-items:center;gap:20px;padding:10px 20px;display:flex;position:sticky;top:0}.brand{white-space:nowrap;font-weight:700}.spark{color:var(--accent)}.run{white-space:nowrap;text-overflow:ellipsis;flex:1;min-width:0;overflow:hidden}.tabs{gap:4px;display:flex}.tabs a{color:var(--muted);border-radius:6px;padding:4px 10px}.tabs a.on{background:var(--hunk-bg);color:var(--ink);font-weight:600}.progress{font-variant-numeric:tabular-nums;align-items:center;gap:8px;display:flex}.progress .bar{background:var(--line);border-radius:3px;width:120px;height:6px;overflow:hidden}.progress .bar div{background:var(--done);height:100%;transition:width .2s}.saving{width:70px;color:var(--muted);font-size:12px}.saving.error{color:var(--del-ink)}.status{vertical-align:-2px;justify-content:center;align-self:center;width:1.2em;display:inline-flex}.status.done{color:var(--done)}.status.partial{color:var(--partial)}.status.todo{color:var(--todo)}.badge{border:1px solid var(--line);color:var(--muted);white-space:nowrap;border-radius:10px;margin-left:6px;padding:0 6px;font-size:11px;display:inline-block}.risk{vertical-align:middle;color:#fff;white-space:nowrap;border-radius:9px;margin-left:8px;padding:0 6px;font-size:11px;font-weight:600;line-height:17px;display:inline-block}.risk-high{background:var(--del-ink)}.risk-med{background:var(--partial)}.issues{border:1px solid var(--line);background:var(--panel);border-radius:8px;margin:12px 0;padding:8px 12px}.issues.has-errors{border-color:var(--del-ink)}.issues li.error{color:var(--del-ink)}.issues li.warning{color:var(--partial)}.page{max-width:1200px;padding:24px 32px 80px}.overview{max-width:800px;font-size:15px}.strategies{grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px;display:grid}.strategy{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;padding:16px;display:flex}.strategy.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.strategy h3{margin:0 0 4px}.strategy .stats{color:var(--muted);font-size:12px}.chunk-list{flex:1;padding-left:0;list-style:none}.chunk-list .n{min-width:1.6em;color:var(--muted);font-variant-numeric:tabular-nums}.chunk-list li{align-items:center;gap:4px;padding:3px 0;display:flex}.chunk-list li .title{flex:1}.chunk-list .branch{color:var(--muted);font-size:11px;display:block}.strategy .hint{color:var(--muted);margin:10px 0 0;font-size:12px}.size{font-variant-numeric:tabular-nums;color:var(--muted);margin-left:6px;font-size:12px}.features li{margin-bottom:6px}.review{grid-template-columns:300px 1fr;align-items:start;display:grid}.sidebar{border-right:1px solid var(--line);max-height:calc(100vh - 53px);padding:16px 8px 16px 16px;position:sticky;top:53px;overflow:auto}.sidebar ol{margin:8px 0;padding:0;list-style:none}.sidebar li a{color:var(--ink);border-radius:6px;align-items:baseline;gap:6px;padding:5px 8px;display:flex}.sidebar li a:hover{background:var(--hunk-bg);text-decoration:none}.sidebar li.on a{background:var(--hunk-bg);font-weight:600}.sidebar li.skim{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px}.sidebar .n{color:var(--muted);font-variant-numeric:tabular-nums;min-width:1.4em}.sidebar .title{flex:1}.chunk-head h1{margin:0 0 2px;font-size:20px}.chunk-head .intent{max-width:820px;margin:6px 0 0;font-size:15px}.focus{max-width:820px;color:var(--muted);margin:6px 0 0;padding-left:18px}.diff{margin-top:16px}.file{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:14px;overflow:hidden}.file>summary{cursor:pointer;border-bottom:1px solid var(--line);align-items:center;gap:4px;padding:8px 12px;display:flex}.file:not([open])>summary{border-bottom:none}.file .path{font-family:var(--mono);font-weight:600}.file .counts{font-family:var(--mono);margin-left:auto;font-size:12px}.add{color:var(--add-ink)}.del{color:var(--del-ink)}.file table{border-collapse:collapse;width:100%;font-family:var(--mono);table-layout:fixed;font-size:12.5px}.file col.no-col{width:52px}.file td.no{text-align:right;color:var(--muted);-webkit-user-select:none;user-select:none;cursor:pointer;vertical-align:top;padding:0 8px}.file td.no:hover{color:var(--accent)}.file td.code{white-space:pre-wrap;word-break:break-all;padding:0 8px}.sign{width:1.2em;color:var(--muted);-webkit-user-select:none;user-select:none;display:inline-block}tr.add td{background:var(--add-bg)}tr.del td{background:var(--del-bg)}tr.meta td{color:var(--muted);font-style:italic}tr.hunk-header td{background:var(--hunk-bg);color:var(--muted);padding:3px 8px}.hid{color:var(--ink);font-weight:700}tr.placeholder td{color:var(--muted);padding:8px;font-family:system-ui,sans-serif}tr.note-row td{background:var(--bg);padding:6px 12px 6px 112px}.line-note{background:var(--note-bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font-family:system-ui,sans-serif;font-size:13px}.line-note .text{white-space:pre-wrap;cursor:text}.line-note .actions{gap:6px;margin-top:6px;display:flex}.line-note .actions button{padding:2px 8px;font-size:12px}.chunk-foot{background:var(--bg);border-top:1px solid var(--line);align-items:flex-end;gap:8px;padding:10px 0;display:flex;position:sticky;bottom:0}.chunk-foot textarea{flex:1;height:34px;min-height:34px;padding:6px 8px;transition:height .1s}.chunk-foot textarea:focus,.chunk-foot textarea:not(:placeholder-shown){height:80px}.totals{margin:0 0 8px;font-size:18px}.chunk-refs a{font-variant-numeric:tabular-nums;margin-left:8px;font-size:12px}.features{padding:0;list-style:none}.note-group{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px;padding:10px 16px}.note-group p{margin:6px 0}.export textarea{min-height:300px;font-family:var(--mono);font-size:12px}@media (width<=900px){.review{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid var(--line);max-height:none;position:static}.page{padding:16px}.run{display:none}}.file{scroll-margin-top:64px}.map-page{flex-direction:column;height:calc(100vh - 53px);display:flex}.crumbs{color:var(--muted);padding:10px 20px;font-size:13px}.map-canvas{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;flex:1;position:relative;overflow:hidden}.map-canvas:active{cursor:grabbing}.empty-map{padding:20px}.zoom-in{transform-box:fill-box;animation:.22s ease-out zoom-in}@keyframes zoom-in{0%{opacity:0}}.node{cursor:pointer}.node rect{fill:var(--panel);stroke:var(--line);stroke-width:1.2px}.node:hover rect{stroke:var(--accent)}.node.neighbour rect{fill:var(--bg);stroke-dasharray:4 3}.node.neighbour text{opacity:.7}.node text{fill:var(--ink);font-size:13px}.node .title{font-weight:600}.node.file .title{font-family:var(--mono);font-size:12.5px}.node .n{fill:var(--muted);font-weight:400}.node .meta{fill:var(--muted);font-size:11px}.node .status.done{color:var(--done)}.node .status.partial{color:var(--partial)}.node .status.todo{color:var(--todo)}.node .risk-high{fill:var(--del-ink)}.node .risk-med{fill:var(--partial)}.edge path{fill:none;stroke:var(--muted);opacity:.7;transition:opacity .15s}.edge.link path{stroke-dasharray:5 4}.edge.faded{opacity:.15}.arrowhead{fill:var(--muted)}.edge-label rect{fill:var(--bg);stroke:var(--line)}.edge-label text{fill:var(--ink);font-size:11px}tr.gap-row td,tr.foreign-row td{background:var(--hunk-bg);color:var(--muted);padding:2px 8px;font-size:12px}tr.gap-row button{margin-right:6px;padding:1px 8px;font-size:12px}tr.line.expanded td.code,tr.line.expanded td.no{opacity:.75}tr.line.expanded td.no{cursor:default}.file summary .view-file{margin-left:8px;padding:1px 8px;font-size:12px}.risk-reason{border-left:3px solid var(--partial);margin:4px 0 0;padding-left:8px;font-size:13px}.risk-reason-high{border-left-color:var(--del-ink)}.rubric{color:var(--muted);margin:0 0 12px;font-size:13px}.rubric p{margin:4px 0}.rubric .risk{margin:0 6px 0 0}
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>spark-joy review</title>
7
7
  <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
8
- <script type="module" crossorigin src="/assets/index-_XxERk0m.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-ChCdW9F8.css">
8
+ <script type="module" crossorigin src="/assets/index-CDcwMAja.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-pgxDJMuQ.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // cli/index.ts
2
- import { copyFile, mkdir as mkdir3, mkdtemp, readFile as readFile4, rm as rm2, writeFile as writeFile3 } from "fs/promises";
2
+ import { copyFile, mkdir as mkdir3, mkdtemp, readFile as readFile6, rm as rm2, writeFile as writeFile3 } from "fs/promises";
3
3
  import { homedir, tmpdir } from "os";
4
- import { join as join4, relative } from "path";
4
+ import { join as join6, relative } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { parseArgs } from "util";
7
7
 
@@ -66,6 +66,8 @@ var Chunk = z.object({
66
66
  dependsOn: z.array(z.string()).default([]),
67
67
  reviewFocus: z.array(z.string()).default([]),
68
68
  risk: Risk.default("low"),
69
+ // Why this chunk is med or high risk, against the agreed rubric.
70
+ riskReason: z.string().min(1).optional(),
69
71
  // Split mode: the branch this chunk becomes.
70
72
  branch: z.string().min(1).optional()
71
73
  });
@@ -119,6 +121,23 @@ var ReviewState = z.object({
119
121
  updatedAt: z.string().optional()
120
122
  });
121
123
  var emptyState = () => ReviewState.parse({});
124
+ var RUBRIC_FILE = ".spark-joy.md";
125
+ function parseRubric(text) {
126
+ const rubric = { high: [], med: [] };
127
+ let inRisk = false;
128
+ for (const line of text.split(/\r?\n/)) {
129
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
130
+ if (heading) {
131
+ inRisk = /^risk$/i.test(heading[1].trim());
132
+ continue;
133
+ }
134
+ const m = inRisk && /^\s*[-*]\s*(high|med|medium)\s*:\s*(.+)$/i.exec(line);
135
+ if (!m) continue;
136
+ const items = m[2].split(",").map((s) => s.trim()).filter(Boolean);
137
+ rubric[m[1].toLowerCase() === "high" ? "high" : "med"].push(...items);
138
+ }
139
+ return rubric.high.length || rubric.med.length ? rubric : void 0;
140
+ }
122
141
 
123
142
  // shared/derive.ts
124
143
  var isIgnorable = (h) => h.flags.some((f) => IGNORABLE_FLAGS.includes(f));
@@ -188,7 +207,8 @@ var BUDGET = {
188
207
  focusWords: 8,
189
208
  featureTitle: 4,
190
209
  featureDescription: 8,
191
- linkLabel: 3
210
+ linkLabel: 3,
211
+ riskReason: 8
192
212
  };
193
213
  var PADDING = [
194
214
  [/,\s*not\b|\bnot just\b|\brather than\b|\binstead of\b/i, "drop the contrast; say only what it does"],
@@ -264,6 +284,10 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
264
284
  if (c.hunks.length === 0) error(cw, "chunk has no hunks");
265
285
  tooLong(cw, "title", c.title, BUDGET.chunkTitle);
266
286
  tooLong(cw, "intent", c.intent, BUDGET.intent);
287
+ if (c.risk === "low") {
288
+ if (c.riskReason) warn(cw, "riskReason on a low-risk chunk; drop it");
289
+ } else if (!c.riskReason) error(cw, `${c.risk} risk needs a riskReason: which rubric item, and where`);
290
+ else tooLong(cw, "riskReason", c.riskReason, BUDGET.riskReason);
267
291
  if (c.reviewFocus.length > BUDGET.focusItems)
268
292
  error(cw, `${c.reviewFocus.length} reviewFocus items; max ${BUDGET.focusItems}`);
269
293
  c.reviewFocus.forEach((q, n) => tooLong(cw, `reviewFocus ${n + 1}`, q, BUDGET.focusWords));
@@ -667,6 +691,8 @@ function computeLinks(hunks) {
667
691
 
668
692
  // cli/lib/git.ts
669
693
  import { execFile } from "child_process";
694
+ import { lstat, readFile } from "fs/promises";
695
+ import { join } from "path";
670
696
  import { promisify } from "util";
671
697
  var exec = promisify(execFile);
672
698
  async function git(cwd, args, input, env) {
@@ -734,31 +760,39 @@ async function generatedByAttr(cwd, files) {
734
760
  }
735
761
  return result;
736
762
  }
763
+ async function fileText(root, meta, path) {
764
+ const source = meta.source ?? (meta.working ? "working" : "pr");
765
+ if (source === "working" || source === "uncommitted") {
766
+ if ((await lstat(join(root, path))).isSymbolicLink()) throw new Error(`${path} is a symlink`);
767
+ return readFile(join(root, path), "utf8");
768
+ }
769
+ return git(root, ["show", `${source === "staged" ? "" : meta.headSha}:${path}`]);
770
+ }
737
771
 
738
772
  // cli/lib/runs.ts
739
- import { mkdir, readFile, readdir, rename, stat, writeFile } from "fs/promises";
740
- import { join } from "path";
773
+ import { mkdir, readFile as readFile2, readdir, rename, stat, writeFile } from "fs/promises";
774
+ import { join as join2 } from "path";
741
775
  import { z as z2 } from "zod";
742
776
  var RUNS_DIR = ".spark-joy";
743
777
  var slug = (s) => s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "run";
744
778
  function runPaths(root, name) {
745
- const dir = join(root, RUNS_DIR, name);
779
+ const dir = join2(root, RUNS_DIR, name);
746
780
  return {
747
781
  name,
748
782
  dir,
749
- hunks: join(dir, "hunks.json"),
750
- meta: join(dir, "meta.json"),
751
- plan: join(dir, "plan.json"),
752
- state: join(dir, "review-state.json"),
753
- review: join(dir, "review.md"),
754
- links: join(dir, "links.json"),
755
- prs: join(dir, "prs.md")
783
+ hunks: join2(dir, "hunks.json"),
784
+ meta: join2(dir, "meta.json"),
785
+ plan: join2(dir, "plan.json"),
786
+ state: join2(dir, "review-state.json"),
787
+ review: join2(dir, "review.md"),
788
+ links: join2(dir, "links.json"),
789
+ prs: join2(dir, "prs.md")
756
790
  };
757
791
  }
758
792
  async function ensureRunsDir(root) {
759
- const dir = join(root, RUNS_DIR);
793
+ const dir = join2(root, RUNS_DIR);
760
794
  await mkdir(dir, { recursive: true });
761
- await writeFile(join(dir, ".gitignore"), "*\n");
795
+ await writeFile(join2(dir, ".gitignore"), "*\n");
762
796
  }
763
797
  var exists = (p) => stat(p).then(() => true, () => false);
764
798
  async function resolveRun(root, name, branch) {
@@ -774,11 +808,11 @@ async function resolveRun(root, name, branch) {
774
808
  return runPaths(root, runs[0].name);
775
809
  }
776
810
  async function listRuns(root) {
777
- const dir = join(root, RUNS_DIR);
811
+ const dir = join2(root, RUNS_DIR);
778
812
  const names = await readdir(dir).catch(() => []);
779
813
  const runs = [];
780
814
  for (const name of names) {
781
- const s = await stat(join(dir, name, "hunks.json")).catch(() => void 0);
815
+ const s = await stat(join2(dir, name, "hunks.json")).catch(() => void 0);
782
816
  if (s) runs.push({ name, mtime: s.mtimeMs });
783
817
  }
784
818
  return runs.sort((a, b) => b.mtime - a.mtime);
@@ -789,7 +823,7 @@ async function writeJson(path, data) {
789
823
  await rename(tmp, path);
790
824
  }
791
825
  async function readJson(path) {
792
- return JSON.parse(await readFile(path, "utf8"));
826
+ return JSON.parse(await readFile2(path, "utf8"));
793
827
  }
794
828
  var readHunks = async (p) => z2.array(Hunk).parse(await readJson(p.hunks));
795
829
  var readMeta = async (p) => Meta.parse(await readJson(p.meta));
@@ -938,7 +972,8 @@ function exportMarkdown(meta, hunks, plan, state) {
938
972
  for (const c of view.chunks) {
939
973
  const status = hunkStatus(c.hunks, reviewed);
940
974
  const changed = status === "partial" ? " \xB7 changed since review" : "";
941
- out.push(`- ${MARK[status]} ${c.index + 1}. ${c.chunk.title} (${c.size})${changed}`);
975
+ const risk = c.chunk.risk !== "low" ? ` \xB7 ${c.chunk.risk}: ${c.chunk.riskReason ?? "unexplained"}` : "";
976
+ out.push(`- ${MARK[status]} ${c.index + 1}. ${c.chunk.title} (${c.size})${risk}${changed}`);
942
977
  const note = state.chunkNotes[chunkNoteKey(view.strategy.id, c.chunk.id)]?.trim();
943
978
  if (note) out.push(...note.split("\n").map((l) => ` > ${l}`));
944
979
  for (const n of notesInHunks(c.hunks, state.lineNotes))
@@ -1038,10 +1073,19 @@ function formatLinks(hunks, links) {
1038
1073
  return [...pairs].sort(([a], [b]) => a.localeCompare(b)).map(([pair, items]) => [pair, ...items.map((i) => ` ${i}`)].join("\n")).join("\n");
1039
1074
  }
1040
1075
 
1076
+ // cli/lib/rubric.ts
1077
+ import { readFile as readFile3 } from "fs/promises";
1078
+ import { join as join3 } from "path";
1079
+ async function readRubric(root) {
1080
+ const text = await readFile3(join3(root, RUBRIC_FILE), "utf8").catch(() => void 0);
1081
+ return text === void 0 ? void 0 : parseRubric(text);
1082
+ }
1083
+ var formatRubric = (r) => [r.high.length ? `high: ${r.high.join(", ")}` : "", r.med.length ? `med: ${r.med.join(", ")}` : ""].filter(Boolean).join("\n");
1084
+
1041
1085
  // cli/lib/server.ts
1042
1086
  import { createServer } from "http";
1043
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1044
- import { extname as extname2, join as join2, normalize, sep } from "path";
1087
+ import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
1088
+ import { extname as extname2, join as join4, normalize, sep } from "path";
1045
1089
  var TYPES = {
1046
1090
  ".html": "text/html; charset=utf-8",
1047
1091
  ".js": "text/javascript; charset=utf-8",
@@ -1051,6 +1095,7 @@ var TYPES = {
1051
1095
  ".map": "application/json"
1052
1096
  };
1053
1097
  var MAX_BODY = 20 * 1024 * 1024;
1098
+ var MAX_FILE = 2 * 1024 * 1024;
1054
1099
  async function body(req) {
1055
1100
  const chunks = [];
1056
1101
  let size = 0;
@@ -1073,6 +1118,7 @@ async function loadRun(paths) {
1073
1118
  readState(paths),
1074
1119
  readLinks(paths)
1075
1120
  ]);
1121
+ const rubric = await readRubric(meta.repoRoot);
1076
1122
  const validation = raw === void 0 ? void 0 : validatePlan(raw, hunks);
1077
1123
  return {
1078
1124
  meta,
@@ -1081,7 +1127,8 @@ async function loadRun(paths) {
1081
1127
  issues: validation?.issues ?? [],
1082
1128
  hasPlan: raw !== void 0,
1083
1129
  links,
1084
- state
1130
+ state,
1131
+ rubric
1085
1132
  };
1086
1133
  }
1087
1134
  async function serve(opts) {
@@ -1097,6 +1144,19 @@ async function serve(opts) {
1097
1144
  return send(res, 403, { error: "cross-origin request refused" });
1098
1145
  }
1099
1146
  if (url.pathname === "/api/run" && req.method === "GET") return send(res, 200, await loadRun(paths));
1147
+ if (url.pathname === "/api/file" && req.method === "GET") {
1148
+ const path = url.searchParams.get("path") ?? "";
1149
+ const [meta, hunks] = await Promise.all([readMeta(paths), readHunks(paths)]);
1150
+ const hs = hunks.filter((h) => h.file === path);
1151
+ if (!hs.length || hs[0].status === "deleted" || hs.some((h) => h.flags.includes("binary")))
1152
+ return send(res, 404, { error: "no text for that file" });
1153
+ const text = await fileText(meta.repoRoot, meta, path).catch(() => void 0);
1154
+ if (text === void 0) return send(res, 404, { error: "no text for that file" });
1155
+ if (text.length > MAX_FILE || text.includes("\0")) return send(res, 404, { error: "file too large or binary" });
1156
+ const lines = text.split("\n");
1157
+ if (lines.at(-1) === "") lines.pop();
1158
+ return send(res, 200, { lines: lines.map((l) => l.replace(/\r$/, "")) });
1159
+ }
1100
1160
  if (url.pathname === "/api/state" && req.method === "PUT") {
1101
1161
  const state = ReviewState.parse(await body(req));
1102
1162
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1113,14 +1173,14 @@ async function serve(opts) {
1113
1173
  if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not found" });
1114
1174
  if (req.method !== "GET") return send(res, 405, { error: "method not allowed" });
1115
1175
  const rel = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
1116
- let file = join2(appDir, rel);
1176
+ let file = join4(appDir, rel);
1117
1177
  if (!file.startsWith(appDir + sep) && file !== appDir) return send(res, 403, { error: "forbidden" });
1118
1178
  let data;
1119
1179
  try {
1120
- data = await readFile2(file);
1180
+ data = await readFile4(file);
1121
1181
  } catch {
1122
- file = join2(appDir, "index.html");
1123
- data = await readFile2(file);
1182
+ file = join4(appDir, "index.html");
1183
+ data = await readFile4(file);
1124
1184
  }
1125
1185
  res.writeHead(200, { "content-type": TYPES[extname2(file)] ?? "application/octet-stream" });
1126
1186
  res.end(data);
@@ -1153,8 +1213,8 @@ async function serve(opts) {
1153
1213
 
1154
1214
  // cli/lib/stack.ts
1155
1215
  import { execFile as execFile2 } from "child_process";
1156
- import { lstat, readFile as readFile3, readlink, rm } from "fs/promises";
1157
- import { join as join3 } from "path";
1216
+ import { lstat as lstat2, readFile as readFile5, readlink, rm } from "fs/promises";
1217
+ import { join as join5 } from "path";
1158
1218
  import { promisify as promisify2 } from "util";
1159
1219
  var execP = promisify2(execFile2);
1160
1220
  var ZERO = "0".repeat(40);
@@ -1222,9 +1282,9 @@ async function targetEntry(root, meta, path) {
1222
1282
  const m = /^(\d+) ([0-9a-f]+) 0\t/.exec(await git(root, ["ls-files", "-s", "-z", "--", path]));
1223
1283
  if (m) e = { mode: m[1], sha: m[2] };
1224
1284
  } else {
1225
- const full = join3(root, path);
1226
- const st = await lstat(full);
1227
- const content = st.isSymbolicLink() ? await readlink(full) : await readFile3(full);
1285
+ const full = join5(root, path);
1286
+ const st = await lstat2(full);
1287
+ const content = st.isSymbolicLink() ? await readlink(full) : await readFile5(full);
1228
1288
  const mode = st.isSymbolicLink() ? "120000" : st.mode & 73 ? "100755" : "100644";
1229
1289
  const sha = st.isSymbolicLink() ? (await git(root, ["hash-object", "-w", "--stdin"], content)).trim() : (await git(root, ["hash-object", "-w", "--", path])).trim();
1230
1290
  e = { mode, sha };
@@ -1330,7 +1390,7 @@ async function createBranches(root, stack, force) {
1330
1390
  async function checkStack(root, stack, cmd, tmp) {
1331
1391
  const results = [];
1332
1392
  for (const pr of stack.prs) {
1333
- const dir = join3(tmp, `pr-${pr.n}`);
1393
+ const dir = join5(tmp, `pr-${pr.n}`);
1334
1394
  await git(root, ["worktree", "add", "--detach", "--quiet", dir, pr.commit]);
1335
1395
  try {
1336
1396
  const r = await execP(cmd, { cwd: dir, shell: true, maxBuffer: 64 * 1024 * 1024 }).then(
@@ -1352,6 +1412,7 @@ function prsMarkdown(meta, stack) {
1352
1412
  out.push(`## ${pr.n}. ${c.title}`, "");
1353
1413
  out.push(`\`${pr.branch}\` \u2192 \`${i === 0 ? target : stack.prs[i - 1].branch}\` \xB7 ${pr.lines} lines`, "");
1354
1414
  out.push(c.intent, "");
1415
+ if (c.risk !== "low") out.push(`**${c.risk === "high" ? "High" : "Medium"} risk:** ${c.riskReason ?? "unexplained"}`, "");
1355
1416
  for (const q of c.reviewFocus) out.push(`- ${q}`);
1356
1417
  if (c.reviewFocus.length) out.push("");
1357
1418
  const deps = c.dependsOn.map((d) => stack.prs.find((p) => p.chunk.id === d)?.n).filter((n) => n && n < pr.n - 1);
@@ -1376,6 +1437,7 @@ Commands:
1376
1437
  show <ids...> Print hunks in full; accepts h003, h003..h010, or a parent id like h012
1377
1438
  --file <path> Print every hunk of a file
1378
1439
  links Cross-file links: which hunk uses a name another hunk defines
1440
+ rubric Print the agreed risk rubric (.spark-joy.md at the repo root)
1379
1441
  validate Check plan.json against the hunks
1380
1442
  --json Machine-readable output
1381
1443
  serve Open the review app
@@ -1430,7 +1492,7 @@ async function main(argv, cwd = process.cwd()) {
1430
1492
  });
1431
1493
  const [cmd, ...rest] = positionals;
1432
1494
  if (values.version) {
1433
- const pkg = JSON.parse(await readFile4(join4(PKG_ROOT, "package.json"), "utf8"));
1495
+ const pkg = JSON.parse(await readFile6(join6(PKG_ROOT, "package.json"), "utf8"));
1434
1496
  console.log(pkg.version);
1435
1497
  return 0;
1436
1498
  }
@@ -1451,6 +1513,7 @@ async function main(argv, cwd = process.cwd()) {
1451
1513
  if (r.untracked.length)
1452
1514
  console.log(`note: ${r.untracked.length} untracked file(s) are not included; \`git add -N <file>\` to include them`);
1453
1515
  if (await readPlanRaw(r.paths)) console.log("note: plan.json already exists and may be stale; run `spark-joy validate`");
1516
+ console.log(await readRubric(r.meta.repoRoot) ? `risk rubric: ${RUBRIC_FILE}` : "no risk rubric yet");
1454
1517
  return 0;
1455
1518
  }
1456
1519
  case "outline": {
@@ -1471,11 +1534,19 @@ async function main(argv, cwd = process.cwd()) {
1471
1534
  console.log(formatLinks(await readHunks(p), await readLinks(p)));
1472
1535
  return 0;
1473
1536
  }
1537
+ case "rubric": {
1538
+ const rubric = await readRubric(await repoRoot(cwd));
1539
+ console.log(rubric ? formatRubric(rubric) : `no risk rubric yet; agree one with the user and write ${RUBRIC_FILE}`);
1540
+ return 0;
1541
+ }
1474
1542
  case "validate": {
1475
1543
  const p = await run(cwd, values.run);
1476
1544
  const raw = await readPlanRaw(p);
1477
1545
  if (raw === void 0) throw new Error(`no plan yet; write ${p.plan}`);
1478
1546
  const result = validatePlan(raw, await readHunks(p));
1547
+ const risky = result.plan?.strategies.some((s) => s.chunks.some((c) => c.risk !== "low"));
1548
+ if (risky && !await readRubric(await repoRoot(cwd)))
1549
+ result.issues.push({ level: "warning", where: "risk", message: `no risk rubric; agree one with the user and write ${RUBRIC_FILE}` });
1479
1550
  if (values.json) {
1480
1551
  console.log(JSON.stringify({ ok: result.ok, issues: result.issues }, null, 2));
1481
1552
  } else {
@@ -1489,7 +1560,7 @@ async function main(argv, cwd = process.cwd()) {
1489
1560
  case "serve": {
1490
1561
  const p = await run(cwd, values.run);
1491
1562
  const port = values.port ? Number(values.port) : 4848;
1492
- const s = await serve({ paths: p, appDir: join4(PKG_ROOT, "dist", "app"), port });
1563
+ const s = await serve({ paths: p, appDir: join6(PKG_ROOT, "dist", "app"), port });
1493
1564
  console.log(`spark-joy reviewing ${p.name} at ${s.url} (Ctrl+C to stop)`);
1494
1565
  await new Promise((resolve) => process.once("SIGINT", resolve));
1495
1566
  await s.close();
@@ -1524,13 +1595,13 @@ async function main(argv, cwd = process.cwd()) {
1524
1595
  hunks,
1525
1596
  plan: result.plan,
1526
1597
  prefix,
1527
- indexFile: join4(p.dir, "split.index")
1598
+ indexFile: join6(p.dir, "split.index")
1528
1599
  });
1529
1600
  for (const pr of stack.prs)
1530
1601
  console.log(`${String(pr.n).padStart(2)} ${pr.branch} ${pr.lines} lines, ${pr.files} files ${pr.chunk.title}`);
1531
1602
  console.log(`${stack.prs.length} PRs; the last one matches your changes exactly`);
1532
1603
  if (values.check !== void 0) {
1533
- const tmp = await mkdtemp(join4(tmpdir(), "spark-joy-check-"));
1604
+ const tmp = await mkdtemp(join6(tmpdir(), "spark-joy-check-"));
1534
1605
  try {
1535
1606
  const results = await checkStack(meta.repoRoot, stack, values.check, tmp);
1536
1607
  for (const r of results) {
@@ -1555,9 +1626,9 @@ nothing created; add --apply to create the branches`);
1555
1626
  return 0;
1556
1627
  }
1557
1628
  case "install-skill": {
1558
- const skill = join4(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md");
1629
+ const skill = join6(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md");
1559
1630
  if (values.print) {
1560
- process.stdout.write(await readFile4(skill, "utf8"));
1631
+ process.stdout.write(await readFile6(skill, "utf8"));
1561
1632
  return 0;
1562
1633
  }
1563
1634
  const homes = { claude: ".claude", agents: ".agents" };
@@ -1566,9 +1637,9 @@ nothing created; add --apply to create the branches`);
1566
1637
  if (bad) throw new Error(`unknown agent ${bad}; use claude or agents`);
1567
1638
  const root = values.project ? await repoRoot(cwd) : homedir();
1568
1639
  for (const a of agents) {
1569
- const dir = join4(root, homes[a], "skills", "spark-joy");
1640
+ const dir = join6(root, homes[a], "skills", "spark-joy");
1570
1641
  await mkdir3(dir, { recursive: true });
1571
- await copyFile(skill, join4(dir, "SKILL.md"));
1642
+ await copyFile(skill, join6(dir, "SKILL.md"));
1572
1643
  console.log(`installed skill to ${dir}`);
1573
1644
  }
1574
1645
  console.log('ask your agent to "review this PR in chunks" or "split my changes into PRs"');