@jpkme/diff-review 0.1.7 → 0.1.10

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.
Files changed (83) hide show
  1. package/bin/diff-review.mjs +7 -2
  2. package/lib/agents.mjs +15 -0
  3. package/lib/check-report.mjs +2 -1
  4. package/lib/check-report.test.mjs +1 -0
  5. package/lib/prompt.mjs +14 -13
  6. package/lib/review-meta.mjs +33 -0
  7. package/lib/review-meta.test.mjs +30 -0
  8. package/lib/review.test.mjs +1 -0
  9. package/lib/validate.mjs +28 -11
  10. package/lib/validate.test.mjs +25 -0
  11. package/lib/wait-output.test.mjs +1 -0
  12. package/package.json +1 -1
  13. package/schema.json +9 -10
  14. package/web/build/client/_app/immutable/assets/{2.COsJT9ly.css → 2.CNFDEnjx.css} +1 -1
  15. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  16. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  17. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css → 3.PHTPLlZ7.css} +1 -1
  18. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  19. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css.gz → 3.PHTPLlZ7.css.gz} +0 -0
  20. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js +2 -0
  21. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  22. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  23. package/web/build/client/_app/immutable/chunks/{D8sAxZVo.js → QdZF3nkf.js} +18 -16
  24. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  25. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  26. package/web/build/client/_app/immutable/entry/{app.Dl9OTE-n.js → app.Dce6uJU3.js} +2 -2
  27. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  28. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  29. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +1 -0
  30. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  31. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  32. package/web/build/client/_app/immutable/nodes/{1.B1c4AH1e.js → 1.5Iv4ssDT.js} +1 -1
  33. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  34. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  35. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +66 -0
  36. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  37. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  38. package/web/build/client/_app/immutable/nodes/{3.C-USexyX.js → 3.CeUs4lBX.js} +2 -2
  39. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  40. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  41. package/web/build/client/_app/version.json +1 -1
  42. package/web/build/client/_app/version.json.br +0 -0
  43. package/web/build/client/_app/version.json.gz +0 -0
  44. package/web/build/handler.js +3 -3
  45. package/web/build/index.js +3 -3
  46. package/web/build/server/chunks/1-2OO2bb7H.js +7 -0
  47. package/web/build/server/chunks/{1-0X6Jp9nH.js.map → 1-2OO2bb7H.js.map} +1 -1
  48. package/web/build/server/chunks/2-BJvuqKN6.js +7 -0
  49. package/web/build/server/chunks/{2-HlcVALg6.js.map → 2-BJvuqKN6.js.map} +1 -1
  50. package/web/build/server/chunks/3-ZIl1rHef.js +7 -0
  51. package/web/build/server/chunks/{3-BN1eS7L2.js.map → 3-ZIl1rHef.js.map} +1 -1
  52. package/web/build/server/chunks/{handler-cq3QoEzW.js → handler-B5P65tLk.js} +3 -3
  53. package/web/build/server/chunks/{handler-cq3QoEzW.js.map → handler-B5P65tLk.js.map} +1 -1
  54. package/web/build/server/chunks/{index.js-D4qkHe3r.js → index.js-CL95e0Gl.js} +2 -2
  55. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +1 -0
  56. package/web/build/server/chunks/{manifest.js-DQHMKyJh.js → manifest.js-uD_a0079.js} +6 -6
  57. package/web/build/server/chunks/{manifest.js-DQHMKyJh.js.map → manifest.js-uD_a0079.js.map} +1 -1
  58. package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js → remote-1g4ayag-B67QMR_2.js} +2 -2
  59. package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js.map → remote-1g4ayag-B67QMR_2.js.map} +1 -1
  60. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  61. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  62. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  63. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +0 -2
  64. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  65. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  66. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  67. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  69. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  70. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +0 -1
  71. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +0 -1
  72. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  73. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  75. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +0 -66
  76. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  77. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  78. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  79. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  80. package/web/build/server/chunks/1-0X6Jp9nH.js +0 -7
  81. package/web/build/server/chunks/2-HlcVALg6.js +0 -7
  82. package/web/build/server/chunks/3-BN1eS7L2.js +0 -7
  83. package/web/build/server/chunks/index.js-D4qkHe3r.js.map +0 -1
@@ -16,13 +16,14 @@ import { createServer } from 'node:net';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import * as p from '@clack/prompts';
19
- import { AGENTS, detectAgents, modelChoices, OTHER_MODEL, runAgent } from '../lib/agents.mjs';
19
+ import { AGENTS, detectAgents, modelChoices, OTHER_MODEL, resolvedRunModel, runAgent } from '../lib/agents.mjs';
20
20
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
21
21
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
22
22
  import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
23
23
  import { makeOutputFilename } from '../lib/output-name.mjs';
24
24
  import { buildPrompt } from '../lib/prompt.mjs';
25
25
  import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mjs';
26
+ import { stampReviewProvenance } from '../lib/review-meta.mjs';
26
27
  import { runValidateCli } from '../lib/check-report.mjs';
27
28
 
28
29
  const here = dirname(fileURLToPath(import.meta.url));
@@ -439,6 +440,10 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
439
440
  process.exit(1);
440
441
  }
441
442
  if (code !== 0) console.warn(`${agent.def.label} terminó con código ${code}, pero el JSON es válido.`);
443
+ stampReviewProvenance(result.path, {
444
+ agent: agent.def.label,
445
+ model: resolvedRunModel(agent.def, model)
446
+ });
442
447
  return /** @type {{ path: string }} */ (result);
443
448
  }
444
449
 
@@ -623,7 +628,7 @@ async function main() {
623
628
  console.log(`Repo: ${launch.repo}`);
624
629
  console.log(`Branch: ${branch} (base: ${base})`);
625
630
  } else {
626
- p.note(`${branch} ← ${base}\n${outputFilename}`, 'Review');
631
+ p.note(`${branch} → ${base}\n${outputFilename}`, 'Review');
627
632
  }
628
633
 
629
634
  const prompt = buildPrompt(
package/lib/agents.mjs CHANGED
@@ -275,6 +275,21 @@ export function modelChoices(def, ctx) {
275
275
  };
276
276
  }
277
277
 
278
+ /**
279
+ * Id de modelo que realmente se usó: el elegido, o el configurado del agente si no se pasó `--model`.
280
+ * @param {AgentDef} def
281
+ * @param {string} [model]
282
+ */
283
+ export function resolvedRunModel(def, model) {
284
+ const chosen = String(model || "").trim();
285
+ if (chosen) return chosen;
286
+ try {
287
+ return def.configuredModel?.(context())?.model || "";
288
+ } catch {
289
+ return "";
290
+ }
291
+ }
292
+
278
293
  /** Comillas para cmd.exe (solo hace falta con los shims .cmd/.bat de npm). */
279
294
  function quoteForCmd(arg) {
280
295
  return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
@@ -159,7 +159,8 @@ export function formatCheckReport(file, command, result) {
159
159
  "How to continue:",
160
160
  "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.",
161
161
  '- Every diff path must appear in "blocks" or "skipped" (a skipped glob counts).',
162
- "- what/why/fix stay ≤120 chars; intent ≥40 chars; enums exactly as in the schema.",
162
+ "- meta.agent and meta.model are required (product name and the exact model id you are running).",
163
+ "- intent ≥40 chars; enums exactly as in the schema.",
163
164
  "- Human-readable strings in Spanish.",
164
165
  `- Re-run exactly: \`${command}\``,
165
166
  "- Stop only when that command prints OK."
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { checkPayload, formatCheckReport } from "./check-report.mjs";
4
4
 
5
5
  const valid = () => ({
6
+ meta: { agent: "Cursor", model: "composer" },
6
7
  intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
7
8
  groups: [{ id: "g1", kind: "feat", title: "CRUD de depósitos", intent: "base de todo" }],
8
9
  blocks: [
package/lib/prompt.mjs CHANGED
@@ -21,16 +21,16 @@ export const OUTPUT_SCHEMA_BLOCK = `{
21
21
  "branch": "<branch under review>",
22
22
  "base": "develop",
23
23
  "remoteUrl": "<origin URL; optional when source=url>",
24
- "generatedAt": "<ISO-8601 datetime; optional, informational>",
25
- "agent": "<tool name e.g. Claude Code / Cursor; optional, informational>",
26
- "model": "<model id e.g. claude-opus; optional, informational>"
24
+ "generatedAt": "<ISO-8601 datetime; optional>",
25
+ "agent": "<required: product name you are, e.g. Claude Code>",
26
+ "model": "<required: exact model id you are running>"
27
27
  },
28
28
  "intent": "string",
29
29
  "groups": [
30
30
  {
31
31
  "id": "g1",
32
32
  "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
33
- "title": "string ≤72 chars, imperative, no kind prefix",
33
+ "title": "string, aim for ≤72 chars, imperative, no kind prefix",
34
34
  "intent": "string"
35
35
  }
36
36
  ],
@@ -44,8 +44,8 @@ export const OUTPUT_SCHEMA_BLOCK = `{
44
44
  "start": 40,
45
45
  "end": 58,
46
46
  "op": "add|mod|del|rename",
47
- "what": "string",
48
- "why": "string",
47
+ "what": "string, aim for ≤120 chars",
48
+ "why": "string, aim for ≤120 chars",
49
49
  "source": "code|commit|pr|issue|inferred"
50
50
  }
51
51
  ],
@@ -59,8 +59,8 @@ export const OUTPUT_SCHEMA_BLOCK = `{
59
59
  "file": "relative/path.ext",
60
60
  "line": 87,
61
61
  "block": "b1",
62
- "what": "string",
63
- "fix": "string"
62
+ "what": "string, aim for ≤120 chars",
63
+ "fix": "string, aim for ≤120 chars"
64
64
  }
65
65
  ],
66
66
  "skipped": [
@@ -76,7 +76,8 @@ const RULES = `- Coverage (hard): every path in the diff MUST appear in "blocks"
76
76
  - Quality findings must name a concrete cost, not a vague feeling.
77
77
  - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
78
78
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
79
- - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
79
+ - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
80
+ - Length is a target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Longer text is still valid. Do not rewrite the file only to shorten it.`;
80
81
 
81
82
  function workflowLocal(base, branch) {
82
83
  return `Workflow:
@@ -122,8 +123,8 @@ export function outputSchemaBlock(meta) {
122
123
  if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
123
124
  metaLines.push(
124
125
  ` "generatedAt": "<ISO-8601 datetime>",`,
125
- ` "agent": "<tool name, e.g. Claude Code / Cursor>",`,
126
- ` "model": "<model id>"`
126
+ ` "agent": "<required: product name you are, e.g. Claude Code>",`,
127
+ ` "model": "<required: exact model id you are running>"`
127
128
  );
128
129
  const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
129
130
  return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
@@ -156,11 +157,11 @@ ${RULES}
156
157
  Write the result to \`${outputFilename}\` at the repo root.
157
158
  - One JSON object only: first character \`{\`, last character \`}\`.
158
159
  - No prose before/after, no markdown fences.
159
- - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; fill generatedAt/agent/model):
160
+ - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
160
161
 
161
162
  ${outputSchemaBlock(meta)}
162
163
 
163
- Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). Also set informational fields: "generatedAt" (ISO-8601 now), "agent" (tool name), "model" (model id). That lets someone reopen the report elsewhere without re-picking the repo.
164
+ Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). "agent" and "model" are required: set "agent" to the product you are (Claude Code, Cursor, Codex, Copilot, Gemini, …) and "model" to the exact model id you are running. Do not leave placeholders or omit them; validation fails without both. "generatedAt" is optional (ISO-8601 now).
164
165
 
165
166
  Do NOT include diffs or a "files" array. The tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
166
167
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Estampa en el JSON quién generó la review. La CLI lo sabe mejor que el modelo.
3
+ */
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { decodePayload } from "./json-payload.mjs";
6
+
7
+ /**
8
+ * @param {string} filePath
9
+ * @param {{ agent?: string, model?: string }} provenance
10
+ * @returns {boolean}
11
+ */
12
+ export function stampReviewProvenance(filePath, { agent, model } = {}) {
13
+ const agentName = String(agent || "").trim();
14
+ const modelId = String(model || "").trim();
15
+ if (!agentName && !modelId) return false;
16
+
17
+ let payload;
18
+ try {
19
+ payload = decodePayload(readFileSync(filePath, "utf8"));
20
+ } catch {
21
+ return false;
22
+ }
23
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
24
+
25
+ const current = payload.meta;
26
+ const meta =
27
+ current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
28
+ if (agentName) meta.agent = agentName;
29
+ if (modelId) meta.model = modelId;
30
+ payload.meta = meta;
31
+ writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
32
+ return true;
33
+ }
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stampReviewProvenance } from "./review-meta.mjs";
7
+
8
+ test("stampReviewProvenance escribe agente y modelo sin perder el resto", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
10
+ const file = join(dir, "diff-rev_a_b.json");
11
+ writeFileSync(file, JSON.stringify({ intent: "algo concreto", meta: { branch: "feat" } }));
12
+
13
+ assert.equal(stampReviewProvenance(file, { agent: "Claude Code", model: "opus" }), true);
14
+
15
+ const saved = JSON.parse(readFileSync(file, "utf8"));
16
+ assert.equal(saved.intent, "algo concreto");
17
+ assert.deepEqual(saved.meta, { branch: "feat", agent: "Claude Code", model: "opus" });
18
+ });
19
+
20
+ test("stampReviewProvenance no pisa el modelo si no hay uno", () => {
21
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
22
+ const file = join(dir, "diff-rev_a_b.json");
23
+ writeFileSync(file, JSON.stringify({ meta: { model: "sonnet" }, intent: "x" }));
24
+
25
+ stampReviewProvenance(file, { agent: "Codex" });
26
+
27
+ const saved = JSON.parse(readFileSync(file, "utf8"));
28
+ assert.equal(saved.meta.agent, "Codex");
29
+ assert.equal(saved.meta.model, "sonnet");
30
+ });
@@ -132,6 +132,7 @@ test("buildReview deduplica hallazgos repetidos y limpia referencias colgadas",
132
132
 
133
133
  test("una respuesta JSON del modelo atraviesa decode, validación y normalización", () => {
134
134
  const answer = JSON.stringify({
135
+ meta: { agent: "Claude Code", model: "claude-opus" },
135
136
  intent: "Hace que las búsquedas con varias palabras matcheen los tokens en cualquier orden.",
136
137
  groups: [
137
138
  { id: "g1", kind: "feat", title: "feat(search): matchear tokens", intent: "el cambio" },
package/lib/validate.mjs CHANGED
@@ -13,9 +13,18 @@ export const SKIP_REASONS = ["generated", "lockfile", "format", "vendored", "bin
13
13
  export const REVIEW_SCHEMA = {
14
14
  $id: "diff-review",
15
15
  type: "object",
16
- required: ["intent", "groups", "blocks"],
16
+ required: ["meta", "intent", "groups", "blocks"],
17
17
  additionalProperties: true,
18
18
  properties: {
19
+ meta: {
20
+ type: "object",
21
+ required: ["agent", "model"],
22
+ additionalProperties: true,
23
+ properties: {
24
+ agent: { type: "string", minLength: 1 },
25
+ model: { type: "string", minLength: 1 },
26
+ },
27
+ },
19
28
  intent: { type: "string", minLength: 40 },
20
29
  groups: {
21
30
  type: "array",
@@ -45,8 +54,8 @@ export const REVIEW_SCHEMA = {
45
54
  file: { type: "string", minLength: 1 },
46
55
  lines: { type: "string", minLength: 1 },
47
56
  op: { type: "string", enum: BLOCK_OPS },
48
- what: { type: "string", minLength: 1, maxLength: 120 },
49
- why: { type: "string", maxLength: 120 },
57
+ what: { type: "string", minLength: 1 },
58
+ why: { type: "string" },
50
59
  source: { type: "string", enum: WHY_SOURCES },
51
60
  },
52
61
  },
@@ -66,8 +75,8 @@ export const REVIEW_SCHEMA = {
66
75
  file: { type: "string", minLength: 1 },
67
76
  line: { type: ["integer", "null"] },
68
77
  block: { type: "string" },
69
- what: { type: "string", minLength: 1, maxLength: 120 },
70
- fix: { type: "string", maxLength: 120 },
78
+ what: { type: "string", minLength: 1 },
79
+ fix: { type: "string" },
71
80
  },
72
81
  },
73
82
  },
@@ -112,9 +121,6 @@ export function validateAgainst(schema, data, path = "$") {
112
121
  if (actual === "string" && schema.minLength && data.length < schema.minLength) {
113
122
  errors.push(`${path}: texto demasiado corto (mínimo ${schema.minLength})`);
114
123
  }
115
- if (actual === "string" && schema.maxLength && data.length > schema.maxLength) {
116
- errors.push(`${path}: texto demasiado largo (máximo ${schema.maxLength})`);
117
- }
118
124
  if (schema.enum && (actual === "string" || actual === "number") && !schema.enum.includes(data)) {
119
125
  errors.push(`${path}: valor no permitido (${data}); usá uno de: ${schema.enum.join(", ")}`);
120
126
  }
@@ -145,6 +151,16 @@ export function validateAgainst(schema, data, path = "$") {
145
151
  */
146
152
  export function reviewIssues(payload, knownPaths = []) {
147
153
  const errors = [];
154
+ const meta = payload?.meta && typeof payload.meta === "object" ? payload.meta : {};
155
+ const agentName = typeof meta.agent === "string" ? meta.agent.trim() : "";
156
+ const modelId = typeof meta.model === "string" ? meta.model.trim() : "";
157
+ if (!agentName || agentName.startsWith("<")) {
158
+ errors.push("meta.agent es obligatorio: nombre del producto (Claude Code, Cursor, Codex, …), sin placeholder");
159
+ }
160
+ if (!modelId || modelId.startsWith("<")) {
161
+ errors.push("meta.model es obligatorio: id exacto del modelo, sin placeholder");
162
+ }
163
+
148
164
  const groups = Array.isArray(payload?.groups) ? payload.groups : [];
149
165
  const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
150
166
  const findings = Array.isArray(payload?.findings) ? payload.findings : [];
@@ -202,9 +218,10 @@ TEXT LANGUAGE: Spanish (rioplatense, technical) for intent, groups.intent, block
202
218
 
203
219
  {
204
220
  "intent": "2-4 sentences on what the branch solves",
205
- "groups": [{ "id": "g1", "kind": "feat", "title": "scope: summary ≤72 chars (do not repeat kind)", "intent": "why this theme exists" }],
206
- "blocks": [{ "id": "b1", "group": "g1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "≤120 chars", "why": "≤120 chars", "source": "code" }],
207
- "findings": [{ "id": "f1", "class": "risk", "severity": "high", "blocking": true, "kind": "bug", "file": "path.ts", "line": 42, "block": "b1", "what": "≤120 chars", "fix": "≤120 chars" }],
221
+ "groups": [{ "id": "g1", "kind": "feat", "title": "scope: summary (do not repeat kind)", "intent": "why this theme exists" }],
222
+ "meta": { "agent": "<required: product name, e.g. Claude Code>", "model": "<required: exact model id>" },
223
+ "blocks": [{ "id": "b1", "group": "g1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "what changed", "why": "why", "source": "code" }],
224
+ "findings": [{ "id": "f1", "class": "risk", "severity": "high", "blocking": true, "kind": "bug", "file": "path.ts", "line": 42, "block": "b1", "what": "what is wrong", "fix": "what to do" }],
208
225
  "skipped": [{ "file": "bun.lock", "reason": "lockfile" }]
209
226
  }
210
227
 
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { REVIEW_SCHEMA, reviewIssues, jsonContract, validateAgainst } from "./validate.mjs";
4
4
 
5
5
  const valid = () => ({
6
+ meta: { agent: "Claude Code", model: "claude-opus" },
6
7
  intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
7
8
  groups: [{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "base de todo" }],
8
9
  blocks: [
@@ -16,6 +17,30 @@ test("validateAgainst acepta una review bien formada", () => {
16
17
  assert.deepEqual(validateAgainst(REVIEW_SCHEMA, valid()), []);
17
18
  });
18
19
 
20
+ test("validateAgainst exige meta.agent y meta.model", () => {
21
+ const missing = valid();
22
+ delete missing.meta;
23
+ assert.ok(validateAgainst(REVIEW_SCHEMA, missing).some((e) => e.includes("meta")));
24
+ assert.ok(reviewIssues(missing).some((e) => e.includes("meta.agent")));
25
+ assert.ok(reviewIssues(missing).some((e) => e.includes("meta.model")));
26
+
27
+ const blank = valid();
28
+ blank.meta = { agent: " ", model: "<required: exact model id>" };
29
+ assert.ok(reviewIssues(blank).some((e) => e.includes("meta.agent")));
30
+ assert.ok(reviewIssues(blank).some((e) => e.includes("meta.model")));
31
+ });
32
+
33
+ test("validateAgainst no limita el largo de what, why ni fix", () => {
34
+ const doc = valid();
35
+ const long = "x".repeat(500);
36
+ doc.blocks[0].what = long;
37
+ doc.blocks[0].why = long;
38
+ doc.findings = [
39
+ { id: "f1", class: "risk", severity: "high", blocking: true, kind: "bug", file: "src/a.ts", line: 4, what: long, fix: long },
40
+ ];
41
+ assert.deepEqual(validateAgainst(REVIEW_SCHEMA, doc), []);
42
+ });
43
+
19
44
  test("validateAgainst exige una intención de verdad", () => {
20
45
  const errors = validateAgainst(REVIEW_SCHEMA, { ...valid(), intent: "corto" });
21
46
  assert.ok(errors.some((e) => e.includes("intent")));
@@ -6,6 +6,7 @@ import { describe, it } from "node:test";
6
6
  import { existingReviewOutput, waitForReviewOutput } from "./wait-output.mjs";
7
7
 
8
8
  const valid = () => ({
9
+ meta: { agent: "Codex", model: "gpt-5" },
9
10
  intent: "Agrega el ABM de depósitos locales end-to-end para dejar de administrarlos por SQL.",
10
11
  groups: [{ id: "g1", kind: "feat", title: "feat(product): CRUD de depósitos", intent: "base de todo" }],
11
12
  blocks: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpkme/diff-review",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "Local UI to prompt a coding agent for a branch review and browse the result.",
5
5
  "type": "module",
6
6
  "bin": {
package/schema.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Diff Review Document",
5
5
  "description": "JSON que escribe el agente (meta/intent/groups/blocks/findings/skipped). La UI completa files con git al abrir el reporte; no hace falta que el modelo incluya diffs.",
6
6
  "type": "object",
7
- "required": ["intent", "groups", "blocks", "findings"],
7
+ "required": ["meta", "intent", "groups", "blocks", "findings"],
8
8
  "properties": {
9
9
  "meta": {
10
10
  "type": "object",
11
11
  "description": "Contexto del repo para reabrir el reporte sin rellenar el formulario. Copiá source/repo/branch/base del prompt.",
12
- "required": ["source", "repo", "branch", "base"],
12
+ "required": ["source", "repo", "branch", "base", "agent", "model"],
13
13
  "properties": {
14
14
  "source": { "type": "string", "enum": ["local", "url"] },
15
15
  "repo": {
@@ -28,11 +28,13 @@
28
28
  },
29
29
  "agent": {
30
30
  "type": "string",
31
- "description": "Nombre del tool/agente (informativo), ej. Claude Code / Cursor."
31
+ "minLength": 1,
32
+ "description": "Nombre del producto que escribió la review, ej. Claude Code o Cursor. Obligatorio."
32
33
  },
33
34
  "model": {
34
35
  "type": "string",
35
- "description": "Id del modelo usado (informativo)."
36
+ "minLength": 1,
37
+ "description": "Id exacto del modelo que escribió la review. Obligatorio."
36
38
  }
37
39
  }
38
40
  },
@@ -57,10 +59,9 @@
57
59
  },
58
60
  "title": {
59
61
  "type": "string",
60
- "maxLength": 72,
61
62
  "description": "Resumen del tema en una línea sin repetir kind (el chip ya lo muestra), ej. 'warehouses: CRUD de depósitos locales' o 'product: alta de depósitos'."
62
63
  },
63
- "intent": { "type": "string", "maxLength": 240, "description": "Por qué existe este tema." }
64
+ "intent": { "type": "string", "description": "Por qué existe este tema." }
64
65
  }
65
66
  }
66
67
  },
@@ -86,12 +87,10 @@
86
87
  "op": { "type": "string", "enum": ["add", "mod", "del", "rename"] },
87
88
  "what": {
88
89
  "type": "string",
89
- "maxLength": 200,
90
90
  "description": "Qué cambia este bloque."
91
91
  },
92
92
  "why": {
93
93
  "type": "string",
94
- "maxLength": 200,
95
94
  "description": "Por qué. Vacío solo si no es inferible."
96
95
  },
97
96
  "source": {
@@ -123,8 +122,8 @@
123
122
  "file": { "type": "string" },
124
123
  "line": { "type": ["integer", "null"], "description": "Línea de la post-imagen (columna derecha / @@ +N)." },
125
124
  "block": { "type": ["string", "null"], "description": "Id del bloque, o vacío/null si es transversal." },
126
- "what": { "type": "string", "maxLength": 300 },
127
- "fix": { "type": "string", "maxLength": 300, "description": "Acción concreta." }
125
+ "what": { "type": "string" },
126
+ "fix": { "type": "string", "description": "Acción concreta." }
128
127
  }
129
128
  }
130
129
  },
@@ -1 +1 @@
1
- .page.svelte-1uha8ag{flex-direction:column;min-height:100vh;display:flex}.diff-backdrop.svelte-1uha8ag{z-index:80;background:color-mix(in srgb, var(--bg) 88%, transparent);-webkit-backdrop-filter:blur(28px);place-items:center;padding:24px;display:grid;position:fixed;inset:0}.diff-modal.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);text-align:center;width:min(380px,100%);padding:28px 24px;box-shadow:0 16px 48px #00000047}.diff-modal.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text);margin:14px 0 0;font-size:15px}.diff-spinner.svelte-1uha8ag{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:28px;height:28px;margin:0 auto;animation:.7s linear infinite svelte-1uha8ag-diff-spin}@keyframes svelte-1uha8ag-diff-spin{to{transform:rotate(360deg)}}.toast.svelte-1uha8ag{z-index:50;border:1px solid var(--border);background:var(--bg-card);color:var(--text);padding:10px 16px;font-size:13px;position:fixed;top:16px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #00000029}.topbar.svelte-1uha8ag{border-bottom:1px solid var(--border);background:var(--header-bg);z-index:5;justify-content:space-between;align-items:center;gap:12px;padding:16px 28px;display:flex;position:sticky;top:0}.brand.svelte-1uha8ag{color:var(--text);align-items:center;gap:10px;text-decoration:none;display:flex}.brand-dot.svelte-1uha8ag{background:linear-gradient(135deg,#6ea8fe,#c9a5ff);border-radius:50%;flex-shrink:0;width:9px;height:9px;box-shadow:0 0 12px #6ea8fe88}.title-block.svelte-1uha8ag{text-align:left;flex-direction:column;line-height:1.25;display:flex}.title-block.svelte-1uha8ag span:where(.svelte-1uha8ag):first-child{font-size:15px;font-weight:700}.title-block.svelte-1uha8ag small:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px;font-weight:400}.workspace.svelte-1uha8ag{flex:1;grid-template-columns:minmax(0,1fr) minmax(300px,380px);align-items:start;gap:28px;width:100%;max-width:1280px;margin:0 auto;padding:22px 28px 40px;display:grid;overflow:auto}.setup.svelte-1uha8ag{min-width:0}h2.svelte-1uha8ag{letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:13px;font-weight:600}.result-head.svelte-1uha8ag{margin-top:24px}.section-head.svelte-1uha8ag{justify-content:space-between;align-items:baseline;margin-top:22px;display:flex}.hint-text.svelte-1uha8ag{color:var(--text-dim);margin:0 0 12px;font-size:12.5px;line-height:1.5}.hint-text.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);font-size:12px}.detect-banner.svelte-1uha8ag,.detect-wait.svelte-1uha8ag{color:var(--text-dim);margin:0 0 10px;font-size:13px}button.file-link.svelte-1uha8ag{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}.error-line.svelte-1uha8ag{color:var(--danger);margin:0 0 10px;font-size:12px}.ok-line.svelte-1uha8ag{color:var(--ok);margin:0 0 10px;font-size:12px}.row.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.repo-setup.svelte-1uha8ag{flex-direction:column;gap:10px;display:flex}.field.svelte-1uha8ag{flex-direction:column;flex:1;gap:4px;min-width:180px;display:flex}.repo-field.svelte-1uha8ag{flex:none;width:100%;min-width:0}.branches-row.svelte-1uha8ag .field:where(.svelte-1uha8ag){flex:1;min-width:160px}.field.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11px}.repo-row.svelte-1uha8ag{gap:6px;min-width:0;display:flex}.repo-row.svelte-1uha8ag input[type=text]:where(.svelte-1uha8ag){flex:1;min-width:0}.repo-row.svelte-1uha8ag button:where(.svelte-1uha8ag){flex-shrink:0}.source-toggle.svelte-1uha8ag{margin-bottom:10px}input[type=text].svelte-1uha8ag,select.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);flex:1;min-width:0;padding:9px 12px}button.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);color:var(--text);padding:9px 12px;font-size:12.5px}button.svelte-1uha8ag:hover:not(:disabled){border-color:var(--accent)}button.svelte-1uha8ag:disabled{opacity:.5;cursor:not-allowed}.mode-toggle.svelte-1uha8ag{border:1px solid var(--border);width:fit-content;margin-bottom:10px;display:flex;overflow:hidden}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag){background:0 0;border:0;padding:6px 12px;font-size:12px}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag)+button:where(.svelte-1uha8ag){border-left:1px solid var(--border)}.mode-toggle.svelte-1uha8ag button.on:where(.svelte-1uha8ag){background:var(--accent-soft)}textarea.svelte-1uha8ag{resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;height:220px;font-family:var(--mono);color:var(--text);padding:12px;font-size:12px;line-height:1.5}.command-box.svelte-1uha8ag{background:var(--bg);border:1px solid var(--border);padding:10px 12px;overflow:auto}.command-box.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);white-space:nowrap;font-size:12.5px}.actions.svelte-1uha8ag{gap:8px;margin-top:10px;display:flex}button.primary.svelte-1uha8ag{background:var(--btn-primary-bg);border-color:var(--btn-primary-border);color:var(--btn-primary-text);font-weight:600}button.ghost.svelte-1uha8ag{color:var(--text-dim);background:0 0}.skill-block.svelte-1uha8ag{border-top:1px solid var(--border);margin-top:16px;padding-top:14px}.skill-block.svelte-1uha8ag h3:where(.svelte-1uha8ag){letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:11.5px;font-weight:650}.skill-buttons.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.coverage-box.svelte-1uha8ag{border:1px solid var(--border);border-left:3px solid var(--sev-med);background:var(--bg-card);flex-direction:column;gap:10px;margin-top:12px;padding:12px 14px;display:flex}.coverage-head.svelte-1uha8ag{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.coverage-head.svelte-1uha8ag strong:where(.svelte-1uha8ag){color:var(--sev-med);font-size:13px}.coverage-head.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px}.coverage-box.svelte-1uha8ag summary:where(.svelte-1uha8ag){color:var(--text-dim);cursor:pointer;font-size:12px}.coverage-list.svelte-1uha8ag{max-height:180px;font-family:var(--mono);color:var(--text-faint);margin:8px 0 0;padding-left:18px;font-size:11px;overflow:auto}.coverage-box.svelte-1uha8ag textarea:where(.svelte-1uha8ag){resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;font-family:var(--mono);color:var(--text);padding:10px;font-size:11.5px;line-height:1.5}.coverage-actions.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.dropzone.svelte-1uha8ag{border:1.5px dashed var(--border);text-align:center;background:var(--bg-elev);padding:26px;transition:background .15s,border-color .15s}.dropzone.drag.svelte-1uha8ag{border-color:var(--accent);background:var(--accent-soft)}.dropzone.busy.svelte-1uha8ag{opacity:.7}.dropzone.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text-dim);margin:0 0 6px;font-size:14px}.visually-hidden.svelte-1uha8ag{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.drop-errors.svelte-1uha8ag{background:var(--danger-bg);color:var(--danger);margin-top:10px;padding:10px 12px;font-size:12.5px}.drop-errors.svelte-1uha8ag strong:where(.svelte-1uha8ag){margin-bottom:4px;display:block}.drop-errors.svelte-1uha8ag ul:where(.svelte-1uha8ag){margin:0;padding-left:18px;line-height:1.6}.reports-col.svelte-1uha8ag{min-width:0;max-height:calc(100vh - 110px);position:sticky;top:88px;overflow:auto}.report-list.svelte-1uha8ag{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.report-item.svelte-1uha8ag{border:1px solid var(--border);background:var(--bg-card);align-items:stretch;display:flex;position:relative;overflow:visible}.report-item.svelte-1uha8ag:hover{background:var(--bg-card-hover);border-color:#33507a}.report-card.svelte-1uha8ag{min-width:0;color:var(--text);flex:1;padding:12px 8px 12px 14px;text-decoration:none;display:block}.report-actions.svelte-1uha8ag{flex-shrink:0;padding:8px 8px 0 0;position:relative}.kebab.svelte-1uha8ag{width:26px;height:26px;color:var(--text-faint);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center;align-items:center;padding:0;display:inline-flex}.kebab.svelte-1uha8ag:hover{border-color:var(--border);color:var(--text);background:var(--bg)}.report-menu.svelte-1uha8ag{z-index:30;border:1px solid var(--border);background:var(--bg-card);min-width:140px;padding:4px;position:absolute;top:auto;bottom:calc(100% - 2px);right:8px;box-shadow:0 8px 24px #0000002e}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag){width:100%;color:var(--text);text-align:left;cursor:pointer;background:0 0;border:0;padding:8px 10px;font-size:12.5px;display:block}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag):hover{background:var(--accent-soft)}.report-menu.svelte-1uha8ag .menu-item.danger:where(.svelte-1uha8ag){color:var(--danger)}.report-top.svelte-1uha8ag{justify-content:space-between;align-items:baseline;gap:12px;display:flex}.report-top.svelte-1uha8ag strong:where(.svelte-1uha8ag){font-size:14px}.when.svelte-1uha8ag{color:var(--text-dim);font-size:12px}.report-meta.svelte-1uha8ag{color:var(--text-dim);flex-wrap:wrap;align-items:center;gap:8px;margin-top:6px;font-size:12px;display:flex}.pill.svelte-1uha8ag{font-family:var(--mono);font-size:11px}.pill.bad.svelte-1uha8ag{color:var(--danger)}.report-intent.svelte-1uha8ag{color:var(--text-dim);-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;margin:8px 0 0;font-size:12.5px;line-height:1.45;display:-webkit-box;overflow:hidden}@media (width<=960px){.workspace.svelte-1uha8ag{grid-template-columns:1fr}.reports-col.svelte-1uha8ag{max-height:none;position:static}}
1
+ .page.svelte-1uha8ag{flex-direction:column;min-height:100vh;display:flex}.diff-backdrop.svelte-1uha8ag{z-index:80;background:color-mix(in srgb, var(--bg) 88%, transparent);-webkit-backdrop-filter:blur(28px);place-items:center;padding:24px;display:grid;position:fixed;inset:0}.diff-modal.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);text-align:center;width:min(380px,100%);padding:28px 24px;box-shadow:0 16px 48px #00000047}.diff-modal.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text);margin:14px 0 0;font-size:15px}.diff-spinner.svelte-1uha8ag{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:28px;height:28px;margin:0 auto;animation:.7s linear infinite svelte-1uha8ag-diff-spin}@keyframes svelte-1uha8ag-diff-spin{to{transform:rotate(360deg)}}.toast.svelte-1uha8ag{z-index:50;border:1px solid var(--border);background:var(--bg-card);color:var(--text);padding:10px 16px;font-size:13px;position:fixed;top:16px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #00000029}.topbar.svelte-1uha8ag{border-bottom:1px solid var(--border);background:var(--header-bg);z-index:5;justify-content:space-between;align-items:center;gap:12px;padding:16px 28px;display:flex;position:sticky;top:0}.brand.svelte-1uha8ag{color:var(--text);align-items:center;gap:10px;text-decoration:none;display:flex}.brand-dot.svelte-1uha8ag{background:linear-gradient(135deg,#6ea8fe,#c9a5ff);border-radius:50%;flex-shrink:0;width:9px;height:9px;box-shadow:0 0 12px #6ea8fe88}.title-block.svelte-1uha8ag{text-align:left;flex-direction:column;line-height:1.25;display:flex}.title-block.svelte-1uha8ag span:where(.svelte-1uha8ag):first-child{font-size:15px;font-weight:700}.title-block.svelte-1uha8ag small:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px;font-weight:400}.workspace.svelte-1uha8ag{flex:1;grid-template-columns:minmax(0,1fr) minmax(300px,380px);align-items:start;gap:28px;width:100%;max-width:1280px;margin:0 auto;padding:22px 28px 40px;display:grid;overflow:auto}.setup.svelte-1uha8ag{min-width:0}h2.svelte-1uha8ag{letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:13px;font-weight:600}.result-head.svelte-1uha8ag{margin-top:24px}.section-head.svelte-1uha8ag{justify-content:space-between;align-items:baseline;margin-top:22px;display:flex}.hint-text.svelte-1uha8ag{color:var(--text-dim);margin:0 0 12px;font-size:12.5px;line-height:1.5}.hint-text.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);font-size:12px}.detect-banner.svelte-1uha8ag,.detect-wait.svelte-1uha8ag{color:var(--text-dim);margin:0 0 10px;font-size:13px}button.file-link.svelte-1uha8ag{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}.error-line.svelte-1uha8ag{color:var(--danger);margin:0 0 10px;font-size:12px}.ok-line.svelte-1uha8ag{color:var(--ok);margin:0 0 10px;font-size:12px}.row.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.repo-setup.svelte-1uha8ag{flex-direction:column;gap:10px;display:flex}.field.svelte-1uha8ag{flex-direction:column;flex:1;gap:4px;min-width:180px;display:flex}.repo-field.svelte-1uha8ag{flex:none;width:100%;min-width:0}.branches-row.svelte-1uha8ag .field:where(.svelte-1uha8ag){flex:1;min-width:160px}.field.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11px}.repo-row.svelte-1uha8ag{gap:6px;min-width:0;display:flex}.repo-row.svelte-1uha8ag input[type=text]:where(.svelte-1uha8ag){flex:1;min-width:0}.repo-row.svelte-1uha8ag button:where(.svelte-1uha8ag){flex-shrink:0}.source-toggle.svelte-1uha8ag{margin-bottom:10px}input[type=text].svelte-1uha8ag,select.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);flex:1;min-width:0;padding:9px 12px}button.svelte-1uha8ag{background:var(--bg-card);border:1px solid var(--border);color:var(--text);padding:9px 12px;font-size:12.5px}button.svelte-1uha8ag:hover:not(:disabled){border-color:var(--accent)}button.svelte-1uha8ag:disabled{opacity:.5;cursor:not-allowed}.mode-toggle.svelte-1uha8ag{border:1px solid var(--border);width:fit-content;margin-bottom:10px;display:flex;overflow:hidden}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag){background:0 0;border:0;padding:6px 12px;font-size:12px}.mode-toggle.svelte-1uha8ag button:where(.svelte-1uha8ag)+button:where(.svelte-1uha8ag){border-left:1px solid var(--border)}.mode-toggle.svelte-1uha8ag button.on:where(.svelte-1uha8ag){background:var(--accent-soft)}textarea.svelte-1uha8ag{resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;height:220px;font-family:var(--mono);color:var(--text);padding:12px;font-size:12px;line-height:1.5}.command-box.svelte-1uha8ag{background:var(--bg);border:1px solid var(--border);padding:10px 12px;overflow:auto}.command-box.svelte-1uha8ag code:where(.svelte-1uha8ag){font-family:var(--mono);color:var(--text);white-space:nowrap;font-size:12.5px}.actions.svelte-1uha8ag{gap:8px;margin-top:10px;display:flex}button.primary.svelte-1uha8ag{background:var(--btn-primary-bg);border-color:var(--btn-primary-border);color:var(--btn-primary-text);font-weight:600}button.ghost.svelte-1uha8ag{color:var(--text-dim);background:0 0}.skill-block.svelte-1uha8ag{border-top:1px solid var(--border);margin-top:16px;padding-top:14px}.skill-block.svelte-1uha8ag h3:where(.svelte-1uha8ag){letter-spacing:.06em;text-transform:uppercase;color:var(--text-faint);margin:0 0 4px;font-size:11.5px;font-weight:650}.skill-buttons.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.coverage-box.svelte-1uha8ag{border:1px solid var(--border);border-left:3px solid var(--sev-med);background:var(--bg-card);flex-direction:column;gap:10px;margin-top:12px;padding:12px 14px;display:flex}.coverage-head.svelte-1uha8ag{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.coverage-head.svelte-1uha8ag strong:where(.svelte-1uha8ag){color:var(--sev-med);font-size:13px}.coverage-head.svelte-1uha8ag span:where(.svelte-1uha8ag){color:var(--text-faint);font-size:11.5px}.coverage-box.svelte-1uha8ag summary:where(.svelte-1uha8ag){color:var(--text-dim);cursor:pointer;font-size:12px}.coverage-list.svelte-1uha8ag{max-height:180px;font-family:var(--mono);color:var(--text-faint);margin:8px 0 0;padding-left:18px;font-size:11px;overflow:auto}.coverage-box.svelte-1uha8ag textarea:where(.svelte-1uha8ag){resize:vertical;background:var(--bg);border:1px solid var(--border);width:100%;font-family:var(--mono);color:var(--text);padding:10px;font-size:11.5px;line-height:1.5}.coverage-actions.svelte-1uha8ag{flex-wrap:wrap;gap:8px;display:flex}.dropzone.svelte-1uha8ag{border:1.5px dashed var(--border);text-align:center;background:var(--bg-elev);padding:26px;transition:background .15s,border-color .15s}.dropzone.drag.svelte-1uha8ag{border-color:var(--accent);background:var(--accent-soft)}.dropzone.busy.svelte-1uha8ag{opacity:.7}.dropzone.svelte-1uha8ag p:where(.svelte-1uha8ag){color:var(--text-dim);margin:0 0 6px;font-size:14px}.visually-hidden.svelte-1uha8ag{clip:rect(0 0 0 0);width:1px;height:1px;position:absolute;overflow:hidden}.drop-errors.svelte-1uha8ag{background:var(--danger-bg);color:var(--danger);margin-top:10px;padding:10px 12px;font-size:12.5px}.drop-errors.svelte-1uha8ag strong:where(.svelte-1uha8ag){margin-bottom:4px;display:block}.drop-errors.svelte-1uha8ag ul:where(.svelte-1uha8ag){margin:0;padding-left:18px;line-height:1.6}.reports-col.svelte-1uha8ag{min-width:0;max-height:calc(100vh - 110px);position:sticky;top:88px;overflow:auto}.report-list.svelte-1uha8ag{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.report-item.svelte-1uha8ag{border:1px solid var(--border);background:var(--bg-card);align-items:stretch;display:flex;position:relative;overflow:visible}.report-item.svelte-1uha8ag:hover{background:var(--bg-card-hover);border-color:#33507a}.report-card.svelte-1uha8ag{min-width:0;color:var(--text);flex:1;padding:12px 8px 12px 14px;text-decoration:none;display:block}.report-actions.svelte-1uha8ag{flex-shrink:0;padding:8px 8px 0 0;position:relative}.kebab.svelte-1uha8ag{width:26px;height:26px;color:var(--text-faint);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center;align-items:center;padding:0;display:inline-flex}.kebab.svelte-1uha8ag:hover{border-color:var(--border);color:var(--text);background:var(--bg)}.report-menu.svelte-1uha8ag{z-index:40;border:1px solid var(--border);background:var(--bg-card);width:148px;padding:4px;position:fixed;box-shadow:0 8px 24px #0000002e}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag){width:100%;color:var(--text);text-align:left;cursor:pointer;background:0 0;border:0;padding:8px 10px;font-size:12.5px;display:block}.report-menu.svelte-1uha8ag .menu-item:where(.svelte-1uha8ag):hover{background:var(--accent-soft)}.report-menu.svelte-1uha8ag .menu-item.danger:where(.svelte-1uha8ag){color:var(--danger)}.report-top.svelte-1uha8ag{justify-content:space-between;align-items:baseline;gap:12px;display:flex}.report-top.svelte-1uha8ag strong:where(.svelte-1uha8ag){font-size:14px}.report-prov.svelte-1uha8ag{min-width:0;color:var(--text-dim);align-items:center;gap:6px;margin-top:4px;font-size:12px;display:flex}.report-prov.svelte-1uha8ag .prov-agent:where(.svelte-1uha8ag){text-overflow:ellipsis;white-space:nowrap;flex:0 auto;min-width:0;overflow:hidden}.report-prov.svelte-1uha8ag .prov-sep:where(.svelte-1uha8ag){color:var(--text-faint);flex-shrink:0}.report-prov.svelte-1uha8ag .prov-model:where(.svelte-1uha8ag){min-width:0;font-family:var(--mono);text-overflow:ellipsis;white-space:nowrap;flex:auto;font-size:11.5px;overflow:hidden}.when.svelte-1uha8ag{color:var(--text-dim);font-size:12px}.report-meta.svelte-1uha8ag{color:var(--text-dim);justify-content:space-between;align-items:baseline;gap:12px;margin-top:6px;font-size:12px;display:flex}.report-base.svelte-1uha8ag{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.base-arrow.svelte-1uha8ag{color:var(--text-faint)}.report-counts.svelte-1uha8ag{flex-shrink:0;align-items:baseline;gap:8px;display:flex}.pill.svelte-1uha8ag{font-family:var(--mono);font-size:11px}.pill.bad.svelte-1uha8ag{color:var(--danger)}.report-intent.svelte-1uha8ag{color:var(--text-dim);-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;margin:8px 0 0;font-size:12.5px;line-height:1.45;display:-webkit-box;overflow:hidden}@media (width<=960px){.workspace.svelte-1uha8ag{grid-template-columns:1fr}.reports-col.svelte-1uha8ag{max-height:none;position:static}}