@dansyuqri/spark-joy 0.2.2 → 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-9PHU75Am.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-D3yLyUyh.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 readFile5, 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 join5, 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));
@@ -948,7 +972,8 @@ function exportMarkdown(meta, hunks, plan, state) {
948
972
  for (const c of view.chunks) {
949
973
  const status = hunkStatus(c.hunks, reviewed);
950
974
  const changed = status === "partial" ? " \xB7 changed since review" : "";
951
- 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}`);
952
977
  const note = state.chunkNotes[chunkNoteKey(view.strategy.id, c.chunk.id)]?.trim();
953
978
  if (note) out.push(...note.split("\n").map((l) => ` > ${l}`));
954
979
  for (const n of notesInHunks(c.hunks, state.lineNotes))
@@ -1048,10 +1073,19 @@ function formatLinks(hunks, links) {
1048
1073
  return [...pairs].sort(([a], [b]) => a.localeCompare(b)).map(([pair, items]) => [pair, ...items.map((i) => ` ${i}`)].join("\n")).join("\n");
1049
1074
  }
1050
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
+
1051
1085
  // cli/lib/server.ts
1052
1086
  import { createServer } from "http";
1053
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1054
- import { extname as extname2, join as join3, 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";
1055
1089
  var TYPES = {
1056
1090
  ".html": "text/html; charset=utf-8",
1057
1091
  ".js": "text/javascript; charset=utf-8",
@@ -1084,6 +1118,7 @@ async function loadRun(paths) {
1084
1118
  readState(paths),
1085
1119
  readLinks(paths)
1086
1120
  ]);
1121
+ const rubric = await readRubric(meta.repoRoot);
1087
1122
  const validation = raw === void 0 ? void 0 : validatePlan(raw, hunks);
1088
1123
  return {
1089
1124
  meta,
@@ -1092,7 +1127,8 @@ async function loadRun(paths) {
1092
1127
  issues: validation?.issues ?? [],
1093
1128
  hasPlan: raw !== void 0,
1094
1129
  links,
1095
- state
1130
+ state,
1131
+ rubric
1096
1132
  };
1097
1133
  }
1098
1134
  async function serve(opts) {
@@ -1137,14 +1173,14 @@ async function serve(opts) {
1137
1173
  if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not found" });
1138
1174
  if (req.method !== "GET") return send(res, 405, { error: "method not allowed" });
1139
1175
  const rel = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
1140
- let file = join3(appDir, rel);
1176
+ let file = join4(appDir, rel);
1141
1177
  if (!file.startsWith(appDir + sep) && file !== appDir) return send(res, 403, { error: "forbidden" });
1142
1178
  let data;
1143
1179
  try {
1144
- data = await readFile3(file);
1180
+ data = await readFile4(file);
1145
1181
  } catch {
1146
- file = join3(appDir, "index.html");
1147
- data = await readFile3(file);
1182
+ file = join4(appDir, "index.html");
1183
+ data = await readFile4(file);
1148
1184
  }
1149
1185
  res.writeHead(200, { "content-type": TYPES[extname2(file)] ?? "application/octet-stream" });
1150
1186
  res.end(data);
@@ -1177,8 +1213,8 @@ async function serve(opts) {
1177
1213
 
1178
1214
  // cli/lib/stack.ts
1179
1215
  import { execFile as execFile2 } from "child_process";
1180
- import { lstat as lstat2, readFile as readFile4, readlink, rm } from "fs/promises";
1181
- import { join as join4 } from "path";
1216
+ import { lstat as lstat2, readFile as readFile5, readlink, rm } from "fs/promises";
1217
+ import { join as join5 } from "path";
1182
1218
  import { promisify as promisify2 } from "util";
1183
1219
  var execP = promisify2(execFile2);
1184
1220
  var ZERO = "0".repeat(40);
@@ -1246,9 +1282,9 @@ async function targetEntry(root, meta, path) {
1246
1282
  const m = /^(\d+) ([0-9a-f]+) 0\t/.exec(await git(root, ["ls-files", "-s", "-z", "--", path]));
1247
1283
  if (m) e = { mode: m[1], sha: m[2] };
1248
1284
  } else {
1249
- const full = join4(root, path);
1285
+ const full = join5(root, path);
1250
1286
  const st = await lstat2(full);
1251
- const content = st.isSymbolicLink() ? await readlink(full) : await readFile4(full);
1287
+ const content = st.isSymbolicLink() ? await readlink(full) : await readFile5(full);
1252
1288
  const mode = st.isSymbolicLink() ? "120000" : st.mode & 73 ? "100755" : "100644";
1253
1289
  const sha = st.isSymbolicLink() ? (await git(root, ["hash-object", "-w", "--stdin"], content)).trim() : (await git(root, ["hash-object", "-w", "--", path])).trim();
1254
1290
  e = { mode, sha };
@@ -1354,7 +1390,7 @@ async function createBranches(root, stack, force) {
1354
1390
  async function checkStack(root, stack, cmd, tmp) {
1355
1391
  const results = [];
1356
1392
  for (const pr of stack.prs) {
1357
- const dir = join4(tmp, `pr-${pr.n}`);
1393
+ const dir = join5(tmp, `pr-${pr.n}`);
1358
1394
  await git(root, ["worktree", "add", "--detach", "--quiet", dir, pr.commit]);
1359
1395
  try {
1360
1396
  const r = await execP(cmd, { cwd: dir, shell: true, maxBuffer: 64 * 1024 * 1024 }).then(
@@ -1376,6 +1412,7 @@ function prsMarkdown(meta, stack) {
1376
1412
  out.push(`## ${pr.n}. ${c.title}`, "");
1377
1413
  out.push(`\`${pr.branch}\` \u2192 \`${i === 0 ? target : stack.prs[i - 1].branch}\` \xB7 ${pr.lines} lines`, "");
1378
1414
  out.push(c.intent, "");
1415
+ if (c.risk !== "low") out.push(`**${c.risk === "high" ? "High" : "Medium"} risk:** ${c.riskReason ?? "unexplained"}`, "");
1379
1416
  for (const q of c.reviewFocus) out.push(`- ${q}`);
1380
1417
  if (c.reviewFocus.length) out.push("");
1381
1418
  const deps = c.dependsOn.map((d) => stack.prs.find((p) => p.chunk.id === d)?.n).filter((n) => n && n < pr.n - 1);
@@ -1400,6 +1437,7 @@ Commands:
1400
1437
  show <ids...> Print hunks in full; accepts h003, h003..h010, or a parent id like h012
1401
1438
  --file <path> Print every hunk of a file
1402
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)
1403
1441
  validate Check plan.json against the hunks
1404
1442
  --json Machine-readable output
1405
1443
  serve Open the review app
@@ -1454,7 +1492,7 @@ async function main(argv, cwd = process.cwd()) {
1454
1492
  });
1455
1493
  const [cmd, ...rest] = positionals;
1456
1494
  if (values.version) {
1457
- const pkg = JSON.parse(await readFile5(join5(PKG_ROOT, "package.json"), "utf8"));
1495
+ const pkg = JSON.parse(await readFile6(join6(PKG_ROOT, "package.json"), "utf8"));
1458
1496
  console.log(pkg.version);
1459
1497
  return 0;
1460
1498
  }
@@ -1475,6 +1513,7 @@ async function main(argv, cwd = process.cwd()) {
1475
1513
  if (r.untracked.length)
1476
1514
  console.log(`note: ${r.untracked.length} untracked file(s) are not included; \`git add -N <file>\` to include them`);
1477
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");
1478
1517
  return 0;
1479
1518
  }
1480
1519
  case "outline": {
@@ -1495,11 +1534,19 @@ async function main(argv, cwd = process.cwd()) {
1495
1534
  console.log(formatLinks(await readHunks(p), await readLinks(p)));
1496
1535
  return 0;
1497
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
+ }
1498
1542
  case "validate": {
1499
1543
  const p = await run(cwd, values.run);
1500
1544
  const raw = await readPlanRaw(p);
1501
1545
  if (raw === void 0) throw new Error(`no plan yet; write ${p.plan}`);
1502
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}` });
1503
1550
  if (values.json) {
1504
1551
  console.log(JSON.stringify({ ok: result.ok, issues: result.issues }, null, 2));
1505
1552
  } else {
@@ -1513,7 +1560,7 @@ async function main(argv, cwd = process.cwd()) {
1513
1560
  case "serve": {
1514
1561
  const p = await run(cwd, values.run);
1515
1562
  const port = values.port ? Number(values.port) : 4848;
1516
- const s = await serve({ paths: p, appDir: join5(PKG_ROOT, "dist", "app"), port });
1563
+ const s = await serve({ paths: p, appDir: join6(PKG_ROOT, "dist", "app"), port });
1517
1564
  console.log(`spark-joy reviewing ${p.name} at ${s.url} (Ctrl+C to stop)`);
1518
1565
  await new Promise((resolve) => process.once("SIGINT", resolve));
1519
1566
  await s.close();
@@ -1548,13 +1595,13 @@ async function main(argv, cwd = process.cwd()) {
1548
1595
  hunks,
1549
1596
  plan: result.plan,
1550
1597
  prefix,
1551
- indexFile: join5(p.dir, "split.index")
1598
+ indexFile: join6(p.dir, "split.index")
1552
1599
  });
1553
1600
  for (const pr of stack.prs)
1554
1601
  console.log(`${String(pr.n).padStart(2)} ${pr.branch} ${pr.lines} lines, ${pr.files} files ${pr.chunk.title}`);
1555
1602
  console.log(`${stack.prs.length} PRs; the last one matches your changes exactly`);
1556
1603
  if (values.check !== void 0) {
1557
- const tmp = await mkdtemp(join5(tmpdir(), "spark-joy-check-"));
1604
+ const tmp = await mkdtemp(join6(tmpdir(), "spark-joy-check-"));
1558
1605
  try {
1559
1606
  const results = await checkStack(meta.repoRoot, stack, values.check, tmp);
1560
1607
  for (const r of results) {
@@ -1579,9 +1626,9 @@ nothing created; add --apply to create the branches`);
1579
1626
  return 0;
1580
1627
  }
1581
1628
  case "install-skill": {
1582
- const skill = join5(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md");
1629
+ const skill = join6(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md");
1583
1630
  if (values.print) {
1584
- process.stdout.write(await readFile5(skill, "utf8"));
1631
+ process.stdout.write(await readFile6(skill, "utf8"));
1585
1632
  return 0;
1586
1633
  }
1587
1634
  const homes = { claude: ".claude", agents: ".agents" };
@@ -1590,9 +1637,9 @@ nothing created; add --apply to create the branches`);
1590
1637
  if (bad) throw new Error(`unknown agent ${bad}; use claude or agents`);
1591
1638
  const root = values.project ? await repoRoot(cwd) : homedir();
1592
1639
  for (const a of agents) {
1593
- const dir = join5(root, homes[a], "skills", "spark-joy");
1640
+ const dir = join6(root, homes[a], "skills", "spark-joy");
1594
1641
  await mkdir3(dir, { recursive: true });
1595
- await copyFile(skill, join5(dir, "SKILL.md"));
1642
+ await copyFile(skill, join6(dir, "SKILL.md"));
1596
1643
  console.log(`installed skill to ${dir}`);
1597
1644
  }
1598
1645
  console.log('ask your agent to "review this PR in chunks" or "split my changes into PRs"');