jig-ui 0.18.10 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0 (2026-09-27)
4
+
5
+ Critiques count owner rulings apart and say what the last round fixed; the gate
6
+ keeps decisions the owner's, and tweak works on its first real pages.
7
+
8
+ ### Added
9
+
10
+ - **A `ruled` verdict.** A finding the owner has already decided is judged
11
+ `ruled`, citing the decision's heading in `ruling`, and counted apart
12
+ (`ruled=`). On jig-site five of a header critique's 15 findings were the
13
+ owner's rulings, counted as findings and sent back to make.
14
+ - **What happened to the last round's findings.** `jig verdicts` compares a
15
+ critique with the one before it in git and prints how many were fixed, are
16
+ still open, were ruled, and are new, with the ids. The report carries it.
17
+ - **`jig verdicts --reprobe`** re-takes probes taken on an older page. The
18
+ gate and `--reprobe` now find the page from the probes themselves, so a spec
19
+ whose `surface:` is a sentence no longer leaves stale probes blocking.
20
+ - **The probe names text drawn in a weight or style no loaded face covers**,
21
+ which the browser fakes from the nearest face. It is an error, like a page in
22
+ the default font. Probe version 8.
23
+ - **`jig update` names the tokens** each refreshed mode file gained or lost,
24
+ and those an edited one did not get, since anything aliasing tokens (a
25
+ Tailwind `@theme`) needs the same change.
26
+
27
+ ### Changed
28
+
29
+ - **`decide` keeps the owner's reasons apart from its own.** A `Why:` is the
30
+ owner's words in quotation marks or `not given`; anything the agent adds goes
31
+ under `**Why (inferred):**`. The gate checks each decision the session wrote
32
+ or changed.
33
+ - **`make` does not edit DECISIONS.md.** The gate stops a make session that
34
+ changed it; decisions go through `decide` or `tweak`.
35
+ - **A spec that no longer matches its approved drawing is not approved.** In
36
+ `spec` and `make`, each region the spec lists must be labelled in its size's
37
+ frame. Region names are read as the spec means them: a parenthetical
38
+ qualifies, "(… only)" is conditional, and a list names several regions.
39
+ - **Critique's reader arms do not read the page's history**: no earlier
40
+ critiques, no git log. Which findings were fixed is the CLI's question.
41
+ - **P-14 says how a menu in a fixed header behaves**: it lies over the page, a
42
+ tap outside closes it and does nothing else, controls in the header row work
43
+ on the first tap, and a long list scrolls inside itself.
44
+ - **M-01's script budget is "none by default"**, not an absolute zero: a
45
+ feature may have script, named in a comment and as small as it allows.
46
+
47
+ ### Fixed
48
+
49
+ - **`tweak`'s gate blocked both of its first real uses, on jig-site.** Three
50
+ causes, all in the gate:
51
+ - It took the session's command from any line that mentioned `/jig`, so the
52
+ command file Jig loads into the session, and tool results quoting it, read
53
+ as `/jig critique`. Only what the user sent counts now.
54
+ - It looked for the critique under the spec's `surface:` field, which on
55
+ jig-site is a sentence. A critique lives under the spec's file name, as
56
+ the command file says.
57
+ - It held the page to its structure at the commit that first approved the
58
+ mockup, before five confirmed, critiqued rounds. It now holds the page to
59
+ its structure at its last critique.
60
+
61
+ The gate also works on the spec the command names (`/jig tweak site-header`),
62
+ not whichever spec was written last.
63
+ - **`jig verdicts` refuses reasons that judge nothing.** On jig-site a render
64
+ arm stopped with 33 of 34 verdicts reading "DRAFT, being refined", and the
65
+ review passed with full counts. A placeholder reason is now an error, and so
66
+ is one reason given word for word by four or more `ok` or `finding` verdicts
67
+ in a file. `n/a` verdicts may still share a reason, since one absence can
68
+ clear many rules.
69
+
70
+ ## 0.19.0 (2026-09-27)
71
+
72
+ A new command, `tweak`, for a small change to a built page that its approved
73
+ mockup does not show.
74
+
75
+ ### Added
76
+
77
+ - **`/jig tweak`: a small change to a built page, in one pass.** On jig-site
78
+ half a day's rounds were changes the approved mockup does not show: a word
79
+ that wrapped, a mark's colour, how a version is written. Each went through
80
+ `decide`, `spec`, `make` and a full critique of every page it touched, or
81
+ would have been an edit by hand, which is the drift Jig exists to catch.
82
+ `tweak` records the decision if the change is one, brings the spec in line if
83
+ it disagrees, builds the change, and has a reader that did not make it
84
+ re-judge only the verdicts the change could affect. The gate bounds it: the
85
+ regions under the spec's `sizes:` must match the ones approved with the
86
+ mockup, the drawing must be unchanged since its approval, and the tweak may
87
+ change only the verdicts it names in `tweak.json`, each re-judged and stamped.
88
+ A change to structure is refused and routed through `spec` and `mockup`.
89
+ - **A critique's lock records each verdict.** `verdicts.lock` now holds a digest
90
+ per verdict as well as the whole-file checksum, so the gate can tell which
91
+ verdicts a tweak changed. A lock written before this release is trusted once
92
+ by the first tweak and rewritten.
93
+
3
94
  ## 0.18.10 (2026-09-26)
4
95
 
5
96
  The rule against a stranded word holds in every browser, and the probe checks
package/README.md CHANGED
@@ -250,7 +250,10 @@ Adopting Jig everywhere is not the price of using it anywhere. The rules apply
250
250
  to what you point them at:
251
251
 
252
252
  - **New work follows the loop** — decide, spec, mockup, make, critique — and the
253
- Stop hook holds it to that, if you asked for the hook.
253
+ Stop hook holds it to that, if you asked for the hook. A small change to a
254
+ built page that its approved mockup does not show (a word, a colour, how text
255
+ wraps) goes through `tweak` instead: decided, specced, built and re-judged in
256
+ one pass, with the page's structure held as approved.
254
257
  - **Old pages sit where they are.** They are not rewritten, and the default
255
258
  `check` says nothing about a file nobody has touched.
256
259
  - **`jig.config.json` can exempt paths** you have no intention of revisiting, and
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as rea
46
46
  import { join as join7 } from "path";
47
47
 
48
48
  // src/adapters/types.ts
49
- var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), check the UI against the rules, or refresh the install.";
49
+ var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), make a small change to a built page (tweak), check the UI against the rules, or refresh the install.";
50
50
  var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
51
51
  function assertSafeRelPath(relPath, adapterName) {
52
52
  const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
@@ -941,6 +941,16 @@ function removeLegacyFiles(projectRoot, relPaths) {
941
941
  }
942
942
 
943
943
  // src/commands/update.ts
944
+ function declaredTokens(css) {
945
+ return new Set([...css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1]));
946
+ }
947
+ function tokenChange(file, before, after, skipped = false) {
948
+ const was = declaredTokens(before);
949
+ const now = declaredTokens(after);
950
+ const added = [...now].filter((t) => !was.has(t)).sort();
951
+ const removed = [...was].filter((t) => !now.has(t)).sort();
952
+ return added.length || removed.length ? { file, added, removed, ...skipped ? { skipped } : {} } : void 0;
953
+ }
944
954
  function update(opts) {
945
955
  const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
946
956
  if (resolved.length === 0) {
@@ -972,7 +982,8 @@ function update(opts) {
972
982
  skipped,
973
983
  fromVersion: resolved[0].manifest.version,
974
984
  toVersion: opts.version,
975
- targets
985
+ targets,
986
+ tokens: initResult.tokens
976
987
  };
977
988
  }
978
989
  function updateTarget(opts, resolved) {
@@ -1058,6 +1069,7 @@ function updateTarget(opts, resolved) {
1058
1069
  function updateInitFiles(opts) {
1059
1070
  const updated = [];
1060
1071
  const skipped = [];
1072
+ const tokens = [];
1061
1073
  const tokensDir = join10(opts.packageRoot, "tokens");
1062
1074
  const initManifest = readInitManifest(opts.projectRoot);
1063
1075
  if (initManifest) {
@@ -1068,11 +1080,21 @@ function updateInitFiles(opts) {
1068
1080
  if (!file.startsWith("mode.")) continue;
1069
1081
  const keys = Object.keys(initManifest.files).filter((k) => k === file || k.endsWith(`/${file}`));
1070
1082
  for (const key of keys) {
1083
+ let before = "";
1084
+ try {
1085
+ before = readFileSync7(join10(opts.projectRoot, ...key.split("/")), "utf8");
1086
+ } catch {
1087
+ }
1088
+ const fresh = readFileSync7(join10(tokensDir, file), "utf8");
1071
1089
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
1072
1090
  skipped.push(key);
1091
+ const change2 = tokenChange(key, before, fresh, true);
1092
+ if (change2) tokens.push(change2);
1073
1093
  continue;
1074
1094
  }
1075
- const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
1095
+ const change = tokenChange(key, before, fresh);
1096
+ if (change) tokens.push(change);
1097
+ const content = vendorHeader(file, opts.version, "css", null) + fresh;
1076
1098
  updated.push(writer.write(key, content));
1077
1099
  initChanged = true;
1078
1100
  }
@@ -1080,7 +1102,7 @@ function updateInitFiles(opts) {
1080
1102
  Object.assign(initFiles, writer.files);
1081
1103
  if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
1082
1104
  }
1083
- return { updated, skipped };
1105
+ return { updated, skipped, tokens };
1084
1106
  }
1085
1107
 
1086
1108
  // src/commands/explain.ts
@@ -5749,7 +5771,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
5749
5771
  import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5750
5772
 
5751
5773
  // src/probe/script.ts
5752
- var PROBE_VERSION = 7;
5774
+ var PROBE_VERSION = 8;
5753
5775
  var PROBE_SCRIPT = `(async () => {
5754
5776
  const doc = document.documentElement;
5755
5777
  // checkVisibility also sees content a closed <details> hides: Chromium hides
@@ -5913,6 +5935,32 @@ var PROBE_SCRIPT = `(async () => {
5913
5935
  focusReturned: document.activeElement === toggle,
5914
5936
  };
5915
5937
  }
5938
+ // A weight or style the page asks for that no loaded face covers: the
5939
+ // browser draws it by thickening or slanting the nearest face it has. On
5940
+ // jig-site a heading asked for a weight the site never loaded and every
5941
+ // review passed it, since the page was styled and nothing was missing.
5942
+ // Only web fonts are judged; a family with no @font-face is the system's.
5943
+ if (document.fonts) await document.fonts.ready;
5944
+ const faces = document.fonts ? [...document.fonts] : [];
5945
+ const unquote = (f) => f.trim().replace(/^["']|["']$/g, '').toLowerCase();
5946
+ const weightOf = (w) => (w === 'normal' ? 400 : w === 'bold' ? 700 : Number(w));
5947
+ const covers = (face, w) => { const [lo, hi = lo] = String(face.weight).trim().split(/\\s+/).map(weightOf); return w >= lo && w <= hi; };
5948
+ const fauxFaces = [];
5949
+ const fauxSeen = new Set();
5950
+ for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
5951
+ if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()) || !vis(el)) continue;
5952
+ const cs = getComputedStyle(el);
5953
+ const first = cs.fontFamily.split(',')[0];
5954
+ const own = faces.filter((f) => unquote(f.family) === unquote(first));
5955
+ if (!own.length) continue;
5956
+ const weight = weightOf(cs.fontWeight);
5957
+ const italic = cs.fontStyle !== 'normal';
5958
+ if (own.some((f) => f.status === 'loaded' && covers(f, weight) && (f.style !== 'normal') === italic)) continue;
5959
+ const key = unquote(first) + ' ' + weight + ' ' + italic;
5960
+ if (fauxSeen.has(key)) continue;
5961
+ fauxSeen.add(key);
5962
+ fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
5963
+ }
5916
5964
  return JSON.stringify({
5917
5965
  jigProbe: ${PROBE_VERSION},
5918
5966
  url: location.href,
@@ -5929,6 +5977,7 @@ var PROBE_SCRIPT = `(async () => {
5929
5977
  navLinksVisible: navAtRest,
5930
5978
  strandedCount: stranded.length,
5931
5979
  strandedWords: stranded.slice(0, 6),
5980
+ fauxFaces: fauxFaces.slice(0, 6),
5932
5981
  head: {
5933
5982
  title: (document.title || '').trim(),
5934
5983
  description: (document.querySelector('meta[name=description]') || {}).content || '',
@@ -6269,6 +6318,18 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6269
6318
  await server.close();
6270
6319
  }
6271
6320
  }
6321
+ function recordedPage(projectRoot, surface) {
6322
+ const dir = join33(projectRoot, ".jig", "critique", surface);
6323
+ if (!existsSync19(dir)) return void 0;
6324
+ for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
6325
+ try {
6326
+ const probe = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6327
+ if (probe.pageFile && existsSync19(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
6328
+ } catch {
6329
+ }
6330
+ }
6331
+ return void 0;
6332
+ }
6272
6333
  async function ensureProbes(opts) {
6273
6334
  const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6274
6335
  const abs = resolve4(opts.projectRoot, opts.page);
@@ -6371,6 +6432,10 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6371
6432
  if (p.defaultFont) {
6372
6433
  errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
6373
6434
  }
6435
+ if (p.fauxFaces?.length) {
6436
+ const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
6437
+ errors.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
6438
+ }
6374
6439
  if (p.unresolvedTokens.length) {
6375
6440
  errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
6376
6441
  }
@@ -6473,6 +6538,43 @@ function decisionHeadings(projectRoot) {
6473
6538
  }
6474
6539
  return headings;
6475
6540
  }
6541
+ function decisionSections(body) {
6542
+ const sections = /* @__PURE__ */ new Map();
6543
+ const lines = body.split("\n");
6544
+ let name;
6545
+ let text = [];
6546
+ const close = () => {
6547
+ if (name && !NOT_A_DECISION.test(name) && !sections.has(name)) sections.set(name, text.join("\n").trim());
6548
+ };
6549
+ for (const line of lines) {
6550
+ const heading = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
6551
+ if (heading) {
6552
+ close();
6553
+ name = heading[1].length >= 2 ? heading[2].replace(/[`*]/g, "").trim() : void 0;
6554
+ text = [];
6555
+ } else text.push(line);
6556
+ }
6557
+ close();
6558
+ return sections;
6559
+ }
6560
+ function unsourcedReasons(current, before) {
6561
+ const then = decisionSections(before);
6562
+ const problems = [];
6563
+ for (const [name, text] of decisionSections(current)) {
6564
+ if (then.get(name) === text) continue;
6565
+ const whys = text.split(/\n\s*\n/).filter((p) => /^\s*\*\*Why\b[^*]*:\*\*/.test(p));
6566
+ for (const why of whys) {
6567
+ const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6568
+ const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
6569
+ if (/inferred/i.test(label)) continue;
6570
+ if (/^not given\b/i.test(said)) continue;
6571
+ if (/["“][^"”]{3,}["”]/.test(said)) continue;
6572
+ problems.push(`"${name}": its \`**${label}:**\` is not the owner's words in quotation marks. Quote what the owner said, write \`not given\`, or put what you added under \`**Why (inferred):**\`.`);
6573
+ break;
6574
+ }
6575
+ }
6576
+ return problems;
6577
+ }
6476
6578
 
6477
6579
  // src/check/spec-shape.ts
6478
6580
  import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
@@ -6485,6 +6587,13 @@ function newestSpec(projectRoot) {
6485
6587
  const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6486
6588
  return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
6487
6589
  }
6590
+ function specFor(projectRoot, surface) {
6591
+ if (surface) {
6592
+ const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6593
+ if (existsSync22(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readFileSync29(path, "utf8") };
6594
+ }
6595
+ return newestSpec(projectRoot);
6596
+ }
6488
6597
  function specProblems(spec) {
6489
6598
  const parts = spec.body.split(/^---\s*$/m);
6490
6599
  const front = parts.length >= 3 ? parts[1] : "";
@@ -6562,7 +6671,28 @@ function specIndexableField(front) {
6562
6671
 
6563
6672
  // src/commands/verdicts.ts
6564
6673
  var VERDICTS = ["ok", "finding", "n/a"];
6674
+ var RULE_VERDICTS = [...VERDICTS, "ruled"];
6565
6675
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
6676
+ var PLACEHOLDER = /^\W*(draft|tbd|todo|placeholder|wip|fixme|xxx|lorem ipsum)\W*($|[,.;:(\u2014-])|\bbeing refined\b|\bto be (judged|written|refined|filled in|completed)\b|\bfill (this )?in later\b/i;
6677
+ var REPEAT_LIMIT = 4;
6678
+ function reasonProblems(file, judged, errors) {
6679
+ const shared = /* @__PURE__ */ new Map();
6680
+ for (const { label, verdict, reason } of judged) {
6681
+ const bare2 = reason.replace(/^[A-Z]{1,2}-\d+[a-z0-9-]*\s*[:\u2014-]?\s*/i, "");
6682
+ if (PLACEHOLDER.test(bare2)) {
6683
+ errors.push(`${file}: ${label} \u2014 "${reason}" is a placeholder, not a judgment. Judge it against the page and write what you saw.`);
6684
+ continue;
6685
+ }
6686
+ if (verdict === "n/a") continue;
6687
+ const key = reason.toLowerCase().replace(/\s+/g, " ");
6688
+ shared.set(key, [...shared.get(key) ?? [], label]);
6689
+ }
6690
+ for (const [, labels] of shared) {
6691
+ if (labels.length < REPEAT_LIMIT) continue;
6692
+ const reason = judged.find((j) => j.label === labels[0]).reason;
6693
+ errors.push(`${file}: ${labels.length} verdicts give the same reason, "${reason}" (${labels.slice(0, 5).join(", ")}${labels.length > 5 ? ", \u2026" : ""}). A reason names what on this page holds or breaks that one rule; judge each of them.`);
6694
+ }
6695
+ }
6566
6696
  function readJson(path, errors) {
6567
6697
  if (!existsSync23(path)) return null;
6568
6698
  try {
@@ -6592,7 +6722,7 @@ function specNeedsNav(projectRoot, surface) {
6592
6722
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6593
6723
  return navField || navRegion;
6594
6724
  }
6595
- function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors) {
6725
+ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
6596
6726
  const total = required.length + extraRequired.length;
6597
6727
  if (file === null) {
6598
6728
  errors.push(`${name}.json is missing \u2014 the ${name} arm has not run, or did not write its verdicts.`);
@@ -6601,7 +6731,9 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6601
6731
  const list = Array.isArray(file.verdicts) ? file.verdicts : [];
6602
6732
  if (!Array.isArray(file.verdicts)) errors.push(`${name}.json has no "verdicts" array.`);
6603
6733
  const seen = /* @__PURE__ */ new Set();
6734
+ const reasons = [];
6604
6735
  let findings = 0;
6736
+ let ruled = 0;
6605
6737
  for (const v of list) {
6606
6738
  const written = typeof v.id === "string" ? v.id.trim() : "";
6607
6739
  const id = written.toUpperCase();
@@ -6619,21 +6751,30 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6619
6751
  errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
6620
6752
  continue;
6621
6753
  }
6622
- if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
6623
- errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
6754
+ if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
6755
+ errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding, ruled or n/a.`);
6624
6756
  continue;
6625
6757
  }
6758
+ if (v.verdict === "ruled") {
6759
+ const ruling = typeof v.ruling === "string" ? v.ruling.trim() : "";
6760
+ const match = decisionList.find((d) => d.toLowerCase() === ruling.toLowerCase());
6761
+ if (!ruling) errors.push(`${name}.json: ${id} is ruled, but names no \`ruling\`. A ruled verdict cites the DECISIONS.md heading that decided it; with none, it is a finding.`);
6762
+ else if (!match) errors.push(`${name}.json: ${id} cites the ruling "${ruling}", which is not a heading in DECISIONS.md. Cite the decision as its heading reads, or judge it a finding.`);
6763
+ else ruled++;
6764
+ }
6626
6765
  const reason = typeof v.reason === "string" ? v.reason.trim() : "";
6627
6766
  if (!reason) errors.push(`${name}.json: ${id} has no reason.`);
6628
6767
  else if (ABSENCE.test(reason)) errors.push(`${name}.json: ${id} \u2014 "${reason}" says the rule was not read. Read it with \`jig explain ${id}\` and judge it.`);
6768
+ else reasons.push({ label: id, verdict: v.verdict, reason });
6629
6769
  if (v.verdict === "finding") findings++;
6630
6770
  }
6771
+ reasonProblems(`${name}.json`, reasons, errors);
6631
6772
  const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
6632
6773
  if (missing.length) {
6633
6774
  errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
6634
6775
  }
6635
6776
  const judged = total - missing.length;
6636
- return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
6777
+ return { state: missing.length ? "incomplete" : "ran", judged, total, findings, ...ruled ? { ruled } : {} };
6637
6778
  }
6638
6779
  function checkDecisions(projectRoot, dir, errors) {
6639
6780
  const required = decisionNames(projectRoot);
@@ -6648,6 +6789,7 @@ function checkDecisions(projectRoot, dir, errors) {
6648
6789
  const list = Array.isArray(file.verdicts) ? file.verdicts : [];
6649
6790
  if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
6650
6791
  const seen = /* @__PURE__ */ new Set();
6792
+ const reasons = [];
6651
6793
  let findings = 0;
6652
6794
  for (const v of list) {
6653
6795
  const name = typeof v.decision === "string" ? v.decision.trim() : "";
@@ -6672,8 +6814,10 @@ function checkDecisions(projectRoot, dir, errors) {
6672
6814
  const reason = typeof v.reason === "string" ? v.reason.trim() : "";
6673
6815
  if (!reason) errors.push(`decisions.json: "${match}" has no reason. Name what on the page satisfies it, or what does not.`);
6674
6816
  else if (ABSENCE.test(reason)) errors.push(`decisions.json: "${match}" \u2014 "${reason}" says the decision was not read.`);
6817
+ else reasons.push({ label: `"${match}"`, verdict: v.verdict, reason });
6675
6818
  if (v.verdict === "finding") findings++;
6676
6819
  }
6820
+ reasonProblems("decisions.json", reasons, errors);
6677
6821
  const unjudged = required.filter((r) => !seen.has(r));
6678
6822
  const since = decisionsSince(projectRoot, dir, unjudged);
6679
6823
  const missing = unjudged.filter((r) => !since.includes(r));
@@ -6726,8 +6870,9 @@ function verifyVerdicts(opts) {
6726
6870
  const screenFile = readJson(join37(dir, "screen.json"), errors);
6727
6871
  const codeFile = readJson(join37(dir, "code.json"), errors);
6728
6872
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6729
- const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
6730
- const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
6873
+ const decisionList = decisionNames(opts.projectRoot);
6874
+ const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
6875
+ const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors, decisionList);
6731
6876
  let rendered = false;
6732
6877
  if (screenFile && screenFile.rendered === true) {
6733
6878
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
@@ -6763,8 +6908,60 @@ function verifyVerdicts(opts) {
6763
6908
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
6764
6909
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
6765
6910
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
6766
- const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}`;
6767
- return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line };
6911
+ const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
6912
+ const previous = previousFindings(opts.projectRoot, dir);
6913
+ return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
6914
+ }
6915
+ function previousFindings(projectRoot, dir) {
6916
+ const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
6917
+ const rel = relative4(projectRoot, dir).split("\\").join("/");
6918
+ const files = ["screen.json", "code.json", "decisions.json"];
6919
+ const paths = files.map((f) => `${rel}/${f}`);
6920
+ let commit;
6921
+ try {
6922
+ const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
6923
+ const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
6924
+ commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
6925
+ } catch {
6926
+ return void 0;
6927
+ }
6928
+ if (!commit) return void 0;
6929
+ const read = (f, at) => {
6930
+ const out = /* @__PURE__ */ new Map();
6931
+ let text;
6932
+ try {
6933
+ text = at ? git(["show", `${at}:./${rel}/${f}`]) : readFileSync30(join37(dir, f), "utf8");
6934
+ } catch {
6935
+ return out;
6936
+ }
6937
+ let body;
6938
+ try {
6939
+ body = JSON.parse(text);
6940
+ } catch {
6941
+ return out;
6942
+ }
6943
+ for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
6944
+ const key = String(v.id ?? v.decision ?? "").trim();
6945
+ if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), v.verdict);
6946
+ }
6947
+ return out;
6948
+ };
6949
+ const before = /* @__PURE__ */ new Map();
6950
+ const now = /* @__PURE__ */ new Map();
6951
+ for (const f of files) {
6952
+ for (const [k, v] of read(f, commit)) before.set(k, v);
6953
+ for (const [k, v] of read(f)) now.set(k, v);
6954
+ }
6955
+ const result = { commit: commit.slice(0, 7), fixed: [], open: [], added: [], ruled: [] };
6956
+ for (const [k, v] of before) {
6957
+ if (v !== "finding") continue;
6958
+ const after = now.get(k);
6959
+ if (after === "finding") result.open.push(k);
6960
+ else if (after === "ruled") result.ruled.push(k);
6961
+ else if (after === "ok" || after === "n/a") result.fixed.push(k);
6962
+ }
6963
+ for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
6964
+ return result;
6768
6965
  }
6769
6966
 
6770
6967
  // src/commands/gate.ts
@@ -6797,23 +6994,7 @@ function mockupDrawingProblems(root, specBody, at) {
6797
6994
  `${at} draws icons or images in its frames (${[...new Set(drawn)].slice(0, 4).join("; ")}). A mockup is structure only: an icon is its name in brackets, \`[search]\`, and an image is a labelled box saying what goes there.`
6798
6995
  );
6799
6996
  }
6800
- const regions = specRegions(specBody);
6801
- for (const [size] of FRAMES) {
6802
- const listed = regions[size];
6803
- const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
6804
- if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
6805
- const named = listed.filter((r) => r.name).map((r) => r.name);
6806
- const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
6807
- if (absent.length) {
6808
- problems.push(
6809
- `${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
6810
- );
6811
- } else if (inFrame.length < listed.length) {
6812
- problems.push(
6813
- `${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
6814
- );
6815
- }
6816
- }
6997
+ problems.push(...frameRegionProblems(frames, specBody, at));
6817
6998
  const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
6818
6999
  const crossed = specSwitches(specBody);
6819
7000
  const recorded = declaredSwitches(root);
@@ -6833,6 +7014,37 @@ function mockupDrawingProblems(root, specBody, at) {
6833
7014
  }
6834
7015
  return problems;
6835
7016
  }
7017
+ function frameRegionProblems(frames, specBody, at) {
7018
+ const problems = [];
7019
+ const regions = specRegions(specBody);
7020
+ for (const [size] of FRAMES) {
7021
+ const listed = regions[size];
7022
+ const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
7023
+ if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
7024
+ const absent = listed.filter((r) => r.name && !r.optional && !regionDrawn(inFrame, r)).map((r) => r.name);
7025
+ if (absent.length) {
7026
+ problems.push(
7027
+ `${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
7028
+ );
7029
+ } else if (inFrame.length < listed.filter((r) => !r.optional).length) {
7030
+ problems.push(
7031
+ `${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.filter((r) => !r.optional).length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
7032
+ );
7033
+ }
7034
+ }
7035
+ return problems;
7036
+ }
7037
+ function approvedDrawingProblems(root, specBody, at) {
7038
+ let html;
7039
+ try {
7040
+ html = readFileSync31(join38(root, at), "utf8");
7041
+ } catch {
7042
+ return [];
7043
+ }
7044
+ const frames = readFrames(html);
7045
+ if (!frames.some((f) => f.size)) return [];
7046
+ return frameRegionProblems(frames, specBody, at);
7047
+ }
6836
7048
  function specSwitches(specBody) {
6837
7049
  const front = specBody.split(/^---\s*$/m)[1] ?? "";
6838
7050
  const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
@@ -6894,7 +7106,18 @@ function regionEntries(block) {
6894
7106
  function regionName(entry) {
6895
7107
  const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
6896
7108
  const head = normalise(text.split(":")[0]);
6897
- return head && head.split(" ").length <= 4 ? { name: head } : {};
7109
+ const optional = /\([^)]*\bonly\b[^)]*\)/.test(head);
7110
+ const bare2 = stripQualifiers(head);
7111
+ if (!bare2 || bare2.split(" ").length > 4) return {};
7112
+ const parts = bare2.split(/\s*,\s*(?:and\s+)?|\s+and\s+(?=[^,]*$)/).map((p) => p.trim()).filter(Boolean);
7113
+ return { name: bare2, ...parts.length > 1 ? { parts } : {}, ...optional ? { optional } : {} };
7114
+ }
7115
+ function stripQualifiers(text) {
7116
+ return text.replace(/\s*\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
7117
+ }
7118
+ function regionDrawn(labels, region) {
7119
+ const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
7120
+ return found(region.name) || !!region.parts && region.parts.every(found);
6898
7121
  }
6899
7122
  function labelMatches(label, name) {
6900
7123
  return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
@@ -6915,8 +7138,9 @@ function sizeBlock2(sizes, size) {
6915
7138
  }
6916
7139
 
6917
7140
  // src/commands/gate.ts
7141
+ import { execFileSync as execFileSync4 } from "child_process";
6918
7142
  var MAX_BLOCKS = 3;
6919
- function lastJigCommand(transcriptPath) {
7143
+ function lastJigInvocation(transcriptPath) {
6920
7144
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6921
7145
  let text;
6922
7146
  try {
@@ -6927,10 +7151,20 @@ function lastJigCommand(transcriptPath) {
6927
7151
  let found;
6928
7152
  for (const line of text.split("\n")) {
6929
7153
  if (!line.includes("/jig")) continue;
6930
- const name = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(line);
6931
- const plain = /(?:^|["\s>])\/jig\s+([a-z]+)/.exec(line);
6932
- const arg = (name?.[1] ?? plain?.[1] ?? "").trim().split(/\s+/)[0];
6933
- if (arg) found = arg.toLowerCase();
7154
+ let entry;
7155
+ try {
7156
+ entry = JSON.parse(line);
7157
+ } catch {
7158
+ continue;
7159
+ }
7160
+ if (entry.type !== "user" || entry.isMeta) continue;
7161
+ const content = entry.message?.content;
7162
+ const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
7163
+ for (const t of texts) {
7164
+ const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(t)?.[1] ?? /^\s*\/jig\s+([^\n]*)/.exec(t)?.[1];
7165
+ const [command, surface] = (args ?? "").trim().split(/\s+/);
7166
+ if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
7167
+ }
6934
7168
  }
6935
7169
  return found;
6936
7170
  }
@@ -6960,7 +7194,7 @@ function asksOwner(text) {
6960
7194
  const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
6961
7195
  return /\?(?=[\s)\]"'*_]|$)/.test(prose);
6962
7196
  }
6963
- var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
7197
+ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
6964
7198
  function sessionStart(transcriptPath) {
6965
7199
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6966
7200
  try {
@@ -6985,18 +7219,27 @@ function verdictsMtime(dir) {
6985
7219
  }
6986
7220
  return newest;
6987
7221
  }
6988
- function surfacesInPlay(root, command, transcriptPath) {
7222
+ function surfacesInPlay(root, command, transcriptPath, surface) {
6989
7223
  const critiqueDir = join39(root, ".jig", "critique");
6990
7224
  if (!existsSync24(critiqueDir)) return [];
6991
7225
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6992
7226
  const start = sessionStart(transcriptPath);
6993
7227
  if (start === void 0) return all;
6994
- const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
7228
+ const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
6995
7229
  return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
6996
7230
  }
6997
- function commandProblems(root, command) {
7231
+ function fileAtSessionStart(root, path, start) {
7232
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7233
+ try {
7234
+ const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
7235
+ return base ? git(["show", `${base}:./${path}`]) : "";
7236
+ } catch {
7237
+ return "";
7238
+ }
7239
+ }
7240
+ function commandProblems(root, command, surface, start) {
6998
7241
  const problems = [];
6999
- const spec = newestSpec(root);
7242
+ const spec = specFor(root, surface);
7000
7243
  if (command === "decide") {
7001
7244
  const found = decisionsFile(root);
7002
7245
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
@@ -7006,9 +7249,10 @@ function commandProblems(root, command) {
7006
7249
  problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
7007
7250
  }
7008
7251
  if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
7252
+ problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start)).map((p) => `DECISIONS.md: ${p}`));
7009
7253
  }
7010
7254
  }
7011
- if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
7255
+ if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
7012
7256
  if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
7013
7257
  else {
7014
7258
  problems.push(...specProblems(spec));
@@ -7027,16 +7271,44 @@ function commandProblems(root, command) {
7027
7271
  }
7028
7272
  if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7029
7273
  }
7274
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
7275
+ if ((command === "spec" || command === "make") && spec) {
7276
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7277
+ const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7278
+ const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7279
+ if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
7280
+ const drift = approvedDrawingProblems(root, spec.body, at);
7281
+ if (drift.length) {
7282
+ problems.push(
7283
+ `${spec.path} says \`mockup: approved\`, but the approved drawing no longer shows what the spec lists: ${drift.join(" ")} ` + (command === "spec" ? `The spec changed after the owner approved the drawing. Set \`mockup: pending\` in the spec; \`/jig mockup\` redraws it for the owner.` : `make builds from a drawing the owner approved of this spec, and this one is of an earlier spec. Stop, and ask for \`/jig mockup\`.`)
7284
+ );
7285
+ }
7286
+ }
7287
+ }
7288
+ if (command === "make") {
7289
+ const found = decisionsFile(root);
7290
+ if (found) {
7291
+ let now = "";
7292
+ try {
7293
+ now = readFileSync32(join39(root, found), "utf8");
7294
+ } catch {
7295
+ }
7296
+ const then = fileAtSessionStart(root, found, start);
7297
+ if (then && now !== then) {
7298
+ problems.push(`${found} changed in a \`make\` session. make carries decisions out; it does not take them. Restore it (\`git checkout -- ${found}\`, or \`git show <commit>:${found}\` if the change is committed), and put what the owner must decide to them: \`/jig decide\`, or \`/jig tweak\` for a small change to a built page.`);
7299
+ }
7300
+ }
7301
+ }
7030
7302
  if (command === "critique") {
7031
7303
  const dir = join39(root, ".jig", "critique");
7032
7304
  const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
7033
- for (const surface of surfaces) {
7034
- const v = verifyVerdicts({ projectRoot: root, surface });
7305
+ for (const surface2 of surfaces) {
7306
+ const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7035
7307
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
7036
- problems.push(`${surface}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
7308
+ problems.push(`${surface2}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
7037
7309
  }
7038
7310
  if (v.ok && v.screen.state === "skipped") {
7039
- problems.push(`${surface}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
7311
+ problems.push(`${surface2}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
7040
7312
  }
7041
7313
  }
7042
7314
  if (surfaces.length === 0) {
@@ -7045,8 +7317,76 @@ function commandProblems(root, command) {
7045
7317
  }
7046
7318
  return problems;
7047
7319
  }
7320
+ function tweakProblems(root, spec) {
7321
+ const problems = [];
7322
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7323
+ if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
7324
+ const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7325
+ if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
7326
+ else problems.push(...structureSinceApproval(root, spec, front));
7327
+ const surface = spec.slug;
7328
+ const record = readTweak(join39(root, ".jig", "critique", surface));
7329
+ if (!record) {
7330
+ problems.push(`.jig/critique/${surface}/tweak.json is missing. It names the change in the owner's words (\`change\`), when it was made (\`at\`) and the rule ids and decisions re-judged for it (\`ids\`).`);
7331
+ } else {
7332
+ if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
7333
+ if (!record.ids.length) problems.push(`.jig/critique/${surface}/tweak.json names nothing to re-judge. A change that no rule and no decision could see needs no tweak; name the ones it can.`);
7334
+ const unjudged = unjudgedTweakIds(join39(root, ".jig", "critique", surface), record);
7335
+ if (unjudged.length) {
7336
+ problems.push(
7337
+ `.jig/critique/${surface}: tweak.json names ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? " and more" : ""} but no verdict for ${unjudged.length === 1 ? "it" : "them"} carries \`"tweak": "${record.at}"\`. Each named verdict is re-judged on the changed page by a reader that did not make the change.`
7338
+ );
7339
+ }
7340
+ }
7341
+ return problems;
7342
+ }
7343
+ function structureSinceApproval(root, spec, front) {
7344
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7345
+ let baseline;
7346
+ let judged = true;
7347
+ try {
7348
+ baseline = git(["log", "-1", "--format=%H", "--", `.jig/critique/${spec.slug}/${LOCK}`]).trim();
7349
+ if (!baseline) {
7350
+ judged = false;
7351
+ baseline = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:", "--", spec.path]).trim();
7352
+ }
7353
+ } catch {
7354
+ return [];
7355
+ }
7356
+ if (!baseline) return [];
7357
+ const when = judged ? `when the last critique judged it (${baseline.slice(0, 7)})` : `when the owner approved the mockup (${baseline.slice(0, 7)})`;
7358
+ const problems = [];
7359
+ let thenBody = "";
7360
+ try {
7361
+ thenBody = git(["show", `${baseline}:./${spec.path}`]);
7362
+ } catch {
7363
+ return [];
7364
+ }
7365
+ const shape = (body) => JSON.stringify(Object.entries(specRegions(body)).sort(([a], [b]) => a.localeCompare(b)).map(([size, regions]) => [size, regions.length, regions.map((r) => r.name ?? "")]));
7366
+ if (shape(thenBody) !== shape(spec.body)) {
7367
+ problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones the page had ${when}. A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
7368
+ }
7369
+ const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7370
+ if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
7371
+ try {
7372
+ if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
7373
+ problems.push(`${at} has changed since ${when.replace(/^when /, "")}. A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
7374
+ }
7375
+ } catch {
7376
+ }
7377
+ }
7378
+ return problems;
7379
+ }
7380
+ function readTweak(dir) {
7381
+ try {
7382
+ const raw = JSON.parse(readFileSync32(join39(dir, "tweak.json"), "utf8"));
7383
+ return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [] };
7384
+ } catch {
7385
+ return void 0;
7386
+ }
7387
+ }
7048
7388
  function surfacePage(projectRoot, surface) {
7049
- const spec = newestSpec(projectRoot);
7389
+ const spec = specFor(projectRoot, surface);
7050
7390
  const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
7051
7391
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7052
7392
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
@@ -7060,9 +7400,10 @@ function gate(opts) {
7060
7400
  if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
7061
7401
  return { block: false, reason: "" };
7062
7402
  }
7063
- const command = lastJigCommand(opts.input.transcript_path);
7403
+ const invocation = lastJigInvocation(opts.input.transcript_path);
7404
+ const command = invocation?.command;
7064
7405
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7065
- const problems = command && !waiting ? commandProblems(root, command).map((p) => `/jig ${command}: ${p}`) : [];
7406
+ const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
7066
7407
  const selection = selectFiles(root, false);
7067
7408
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7068
7409
  if (changedUi) {
@@ -7083,10 +7424,10 @@ function gate(opts) {
7083
7424
  );
7084
7425
  }
7085
7426
  }
7086
- problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
7427
+ problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
7087
7428
  const critiqueDir = join39(root, ".jig", "critique");
7088
7429
  if (existsSync24(critiqueDir)) {
7089
- for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
7430
+ for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
7090
7431
  const dir = join39(critiqueDir, surface);
7091
7432
  if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
7092
7433
  const v = verifyVerdicts({ projectRoot: root, surface });
@@ -7135,6 +7476,28 @@ ${problems.join("\n\n")}`
7135
7476
  }
7136
7477
  var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
7137
7478
  var LOCK = "verdicts.lock";
7479
+ function verdictDigests(dir) {
7480
+ const out = {};
7481
+ for (const f of VERDICT_FILES) {
7482
+ let file;
7483
+ try {
7484
+ file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
7485
+ } catch {
7486
+ continue;
7487
+ }
7488
+ for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
7489
+ const key = String(v.id ?? v.decision ?? "").trim();
7490
+ if (key) out[`${f}:${key}`] = checksum(JSON.stringify(v));
7491
+ }
7492
+ }
7493
+ return out;
7494
+ }
7495
+ function writeLock(lockPath, dir, now) {
7496
+ try {
7497
+ writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
7498
+ } catch {
7499
+ }
7500
+ }
7138
7501
  function verdictChecksum(dir) {
7139
7502
  const parts = VERDICT_FILES.map((f) => {
7140
7503
  try {
@@ -7157,18 +7520,22 @@ function verdictGuard(root, command, inPlay) {
7157
7520
  if (!now) continue;
7158
7521
  const lockPath = join39(dir, LOCK);
7159
7522
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7160
- try {
7161
- writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
7162
- } catch {
7163
- }
7523
+ writeLock(lockPath, dir, now);
7164
7524
  continue;
7165
7525
  }
7166
- let locked;
7526
+ let lock = {};
7167
7527
  try {
7168
- locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
7528
+ lock = JSON.parse(readFileSync32(lockPath, "utf8"));
7169
7529
  } catch {
7170
7530
  continue;
7171
7531
  }
7532
+ const locked = lock.checksum;
7533
+ if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
7534
+ const own = tweakVerdictProblems(dir, surface, lock.verdicts);
7535
+ problems.push(...own);
7536
+ if (own.length === 0) writeLock(lockPath, dir, now);
7537
+ continue;
7538
+ }
7172
7539
  if (locked && locked !== now) {
7173
7540
  problems.push(
7174
7541
  `.jig/critique/${surface}: the verdict files changed after \`/jig critique\` wrote them${command ? `, in a session that ran \`/jig ${command}\`` : ""}. Verdicts are the review's, not the builder's: restore them (\`git checkout -- .jig/critique/${surface}\`) and run \`/jig critique\` to judge the fix.`
@@ -7177,6 +7544,40 @@ function verdictGuard(root, command, inPlay) {
7177
7544
  }
7178
7545
  return problems;
7179
7546
  }
7547
+ function tweakVerdictProblems(dir, surface, lockedVerdicts) {
7548
+ const record = readTweak(dir);
7549
+ if (!record) {
7550
+ return [`.jig/critique/${surface}: the verdict files changed in a \`/jig tweak\` session with no tweak.json naming what was re-judged.`];
7551
+ }
7552
+ const problems = [];
7553
+ const named = new Set(record.ids.map((id) => id.toLowerCase()));
7554
+ const nowVerdicts = verdictDigests(dir);
7555
+ if (lockedVerdicts) {
7556
+ const keys = /* @__PURE__ */ new Set([...Object.keys(lockedVerdicts), ...Object.keys(nowVerdicts)]);
7557
+ const outside = [...keys].filter((k) => lockedVerdicts[k] !== nowVerdicts[k]).map((k) => k.slice(k.indexOf(":") + 1)).filter((id) => !named.has(id.toLowerCase()));
7558
+ if (outside.length) {
7559
+ problems.push(
7560
+ `.jig/critique/${surface}: a tweak changed verdicts it did not name (${[...new Set(outside)].slice(0, 6).join(", ")}). A tweak re-judges only what its change could affect, listed in tweak.json's \`ids\`; restore the others (\`git checkout -- .jig/critique/${surface}\`).`
7561
+ );
7562
+ }
7563
+ }
7564
+ return problems;
7565
+ }
7566
+ function unjudgedTweakIds(dir, record) {
7567
+ const stamped = /* @__PURE__ */ new Set();
7568
+ for (const f of VERDICT_FILES) {
7569
+ let file;
7570
+ try {
7571
+ file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
7572
+ } catch {
7573
+ continue;
7574
+ }
7575
+ for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
7576
+ if (record.at && v.tweak === record.at) stamped.add(String(v.id ?? v.decision ?? "").toLowerCase());
7577
+ }
7578
+ }
7579
+ return record.ids.filter((id) => !stamped.has(id.toLowerCase()));
7580
+ }
7180
7581
  function save(file, state) {
7181
7582
  try {
7182
7583
  mkdirSync8(join39(file, ".."), { recursive: true });
@@ -7375,19 +7776,42 @@ program.command("update").description("Update vendored Jig rules, skipping files
7375
7776
  );
7376
7777
  for (const f of result.updated) console.log(` ~ ${f}`);
7377
7778
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
7779
+ for (const t of result.tokens) {
7780
+ const parts = [t.added.length ? `adds ${t.added.join(", ")}` : "", t.removed.length ? `removes ${t.removed.join(", ")}` : ""].filter(Boolean).join("; ");
7781
+ console.log(t.skipped ? ` ! ${t.file}: this release ${parts}, and did not apply it, because the file is edited locally.` : ` + ${t.file}: ${parts}.`);
7782
+ }
7783
+ if (result.tokens.length) console.log(" Anything that aliases tokens (a Tailwind @theme, a generated utilities file) needs the same change.");
7378
7784
  } catch (err) {
7379
7785
  console.error(err.message);
7380
7786
  process.exit(1);
7381
7787
  }
7382
7788
  });
7383
- program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").action((surface) => {
7789
+ program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").option("--reprobe", "re-take every probe that is missing or was taken on an older version of the page, first (needs a browser)").action(async (surface, opts) => {
7384
7790
  const projectRoot = findProjectRoot(process.cwd());
7385
7791
  try {
7792
+ if (opts.reprobe) {
7793
+ const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
7794
+ if (!page) console.error(` \u2717 --reprobe: no probe in .jig/critique/${surface}/ names a page that exists, so there is nothing to render. Run \`jig probe --run <page> --save ${surface}\`.`);
7795
+ else {
7796
+ const { recorded, reason } = await ensureProbes({ projectRoot, surface, page });
7797
+ if (reason) console.error(` \u2717 --reprobe: ${reason}.`);
7798
+ else if (recorded.length) console.log(` Re-probed ${page} at ${recorded.join(", ")}px.`);
7799
+ else console.log(` Every probe is current.`);
7800
+ }
7801
+ }
7386
7802
  const result = verifyVerdicts({ projectRoot, surface });
7387
7803
  for (const error of result.errors) console.error(` \u2717 ${error}`);
7804
+ if (!opts.reprobe && result.errors.some((e) => /was taken (on|by) an older/.test(e))) {
7805
+ console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
7806
+ }
7388
7807
  if (result.ok) console.log(` Every rule in both passes has a verdict.`);
7389
7808
  const since = result.decisions.since ?? [];
7390
7809
  if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
7810
+ const p = result.previous;
7811
+ if (p) {
7812
+ const list = (ids) => ids.length ? ` (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", \u2026" : ""})` : "";
7813
+ console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.open.length} still open${list(p.open)}, ${p.ruled.length} ruled by the owner${list(p.ruled)}, ${p.added.length} new${list(p.added)}.`);
7814
+ }
7391
7815
  console.log(` ${result.line}`);
7392
7816
  process.exit(result.ok ? 0 : 1);
7393
7817
  } catch (err) {
@@ -7485,7 +7909,7 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
7485
7909
  try {
7486
7910
  const projectRoot = findProjectRoot(cwd);
7487
7911
  for (const surface of critiquedSurfaces(projectRoot)) {
7488
- const page = surfacePage(projectRoot, surface);
7912
+ const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
7489
7913
  if (!page) continue;
7490
7914
  try {
7491
7915
  await ensureProbes({ projectRoot, surface, page });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.18.10",
3
+ "version": "0.20.0",
4
4
  "description": "A design system for coding agents. 143 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/rules/01-modes.md CHANGED
@@ -86,7 +86,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
86
86
  - Every page states its subject above the fold in text, not only in an image.
87
87
  - Prose blocks are measure-capped even when the container is wide.
88
88
  - No horizontal scrolling regions on mobile. Reflow instead.
89
- - Total JS budget for a content page: **0 KB** unless a specific feature requires it. Interactivity is opt-in per component and must be justified in a comment. **Keyboard access a rule requires is not a feature and is not counted**: a few lines whose only job is behaviour a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
89
+ - JavaScript: **none by default.** A content page ships no script unless a feature on it needs some: a theme toggle, a search box, a filter. That is a default, not an absolute zero. Each script names, in a comment, the feature and the decision or rule that asks for it, and a review judges whether the feature needs it and whether it is as small as the feature allows, not whether it exists. What the budget forbids is script no feature asks for: a framework runtime to render static text, a library for one effect, tracking bundled into the page. **Behaviour a rule requires is not a feature and is not counted**: a few lines whose only job is something a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu, or a tap outside closing a menu in a fixed header (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
90
90
 
91
91
  ---
92
92
 
@@ -476,11 +476,16 @@ Compose it for the phone first. Mobile navigation is a different control — not
476
476
  - Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
477
477
  - The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
478
478
  - Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
479
- - **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
479
+ - **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where a page has no script by default (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
480
480
  - **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
481
481
  - **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
482
482
  - **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
483
483
  - **An open menu does not cover the page unless it has to.** If it does cover the page, it is a dialog and `P-07` applies: focus moves into it, `Escape` closes it, and focus returns to the button. A menu that opens inline needs none of that — but `Escape` still closes it and returns focus to the button.
484
+ - **In a fixed or sticky header, the open menu lies over the page.** It cannot push the page down, so it covers what is under it, and it is still not a dialog: focus is not trapped and the page is not made inert. What it owes the reader instead:
485
+ - A tap outside the menu closes it, and that tap is spent closing it. It does not also follow the link or press the button that lay under it, so nothing on the page is activated by accident.
486
+ - A control in the header row itself — the theme toggle, a search button, the site name — works on the first tap: the menu closes and the control does its job. Only a tap on the page below is swallowed.
487
+ - When the list is taller than the screen below the header, the menu scrolls inside itself, so its last link is reachable without scrolling the page under it.
488
+ - These need a few lines of script where `<details>` alone is used; they are behaviour this rule requires, counted as `M-01` counts `Escape`.
484
489
  - **A sticky header at phone width is one row.** Two sticky rows permanently spend a sixth of a phone's height on chrome.
485
490
  - **The wide row appears where the labels fit, not at a device width.** Set the breakpoint from the content — the width at which every destination sits on one line at full touch size — so a longer label moves the breakpoint instead of breaking the row.
486
491
  - **`operator`:** the wide screen is the real case. At phone width, one **Menu** button for everything is enough, and keyboard operation of the open menu is mandatory (`M-03`).
@@ -12,7 +12,7 @@ then do the work below for that subcommand. Read the command's full output —
12
12
  findings are ordered by severity, not position, so `head`, `tail`, `grep` and
13
13
  `jq` drop the ones that matter.
14
14
 
15
- **Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`. There is no binary. Do not try to run one:
15
+ **Agent procedures** — `decide`, `spec`, `mockup`, `make`, `critique`, `tweak`. There is no binary. Do not try to run one:
16
16
  the section below **is** the command. A CLI can check what these produce; it
17
17
  cannot do their work, because the work is judgment and authorship.
18
18
 
@@ -26,6 +26,9 @@ spec what exactly is being built: its smallest useful version, at every sc
26
26
  mockup low-fidelity design of that spec, reviewed before any code
27
27
  make high-fidelity: the actual page or feature, built from the spec & mockup
28
28
  critique scrutinises what was built against the rules, its spec & mockup
29
+
30
+ tweak a small change the approved mockup does not show: decided if it is a
31
+ decision, specced, built, and re-judged where it could matter
29
32
  ```
30
33
 
31
34
  ## init
@@ -461,6 +464,14 @@ spec is not a weaker spec — it is not a spec, and `make` will refuse it.
461
464
  If the user changes something, revise and ask again. The run is incomplete until
462
465
  they confirm.
463
466
 
467
+ **Revising a spec whose drawing was approved.** If the revision adds, removes or
468
+ renames a region at any size, the approved drawing no longer shows the page: set
469
+ `mockup: pending`, and `{{command_prefix}}mockup` redraws it for the owner. The gate
470
+ checks it for a drawing in frames: while `mockup: approved`, each region the spec lists must be labelled in
471
+ its size's frame of the drawing at `mockup_at:`. On jig-site the header spec went
472
+ through five confirmed rounds after its drawing was approved, and still said
473
+ `approved` over a drawing of none of them.
474
+
464
475
  ## mockup
465
476
 
466
477
  **Low-fidelity design.** Draw the confirmed spec in grayscale, at every size it
@@ -721,6 +732,16 @@ between them yourself.
721
732
 
722
733
  If `mockup: skipped`, build from the spec alone.
723
734
 
735
+ **`make` does not edit `DECISIONS.md`.** The file records what the owner decided,
736
+ and `make` is where decisions are carried out, not taken. When the build shows a
737
+ decision is wrong, missing or in conflict with the spec, stop and say so: the owner
738
+ changes it through `{{command_prefix}}decide`, or `{{command_prefix}}tweak` for a
739
+ small change to a built page. A note that a decision has been carried out belongs in
740
+ the spec, under `deviations:` or its as-built notes, not in the decision. On jig-site
741
+ one make round added a decision and another rewrote one to match the tokens it had
742
+ just switched to; the next critique judged the page against text the build had
743
+ written. The gate stops a `make` session that changed `DECISIONS.md`.
744
+
724
745
  Build V1 only. Nothing in `later:`, however little extra it looks.
725
746
 
726
747
  Build the **phone** composition first, then add what `tablet` and `desktop`
@@ -874,16 +895,38 @@ A and C are kept apart for a narrower reason: an arm that has seen the render
874
895
  stops reading the source and starts confirming the picture. They answer to
875
896
  different evidence and must not share it.
876
897
 
898
+ **Nor does either arm read the page's history.** Earlier critiques' verdict
899
+ files, their reports, the folders set aside beside them, and the project's git
900
+ log and commit messages all say what a reviewer found before, and an arm that
901
+ has read "the header critique, 17 findings" judges the page it is shown against
902
+ that list. Tell each arm so in its brief: it reads the page, the spec, the
903
+ mockup, `DECISIONS.md` and the corpus, and runs no `git log`, `git show` or
904
+ `git diff`, and opens nothing else under `.jig/critique/`. Which earlier findings
905
+ were fixed is not the arms' question; `verdicts` answers it from git after both
906
+ have written (step 4).
907
+
877
908
  ### 1a. Arms A and C walk the index. They do not search it.
878
909
 
879
910
  **This is not a style preference. It is the difference between covering the
880
911
  corpus and sampling it.**
881
912
 
882
913
  Load `rules.index.json`, filter to the pass the arm owns, and return a verdict
883
- for **every id in that list**: `ok`, `finding`, or `n/a` with one line of
884
- reason. A rule that does not apply is still answered — `n/a, this page has no
914
+ for **every id in that list**: `ok`, `finding`, `ruled`, or `n/a` with one line
915
+ of reason. A rule that does not apply is still answered — `n/a, this page has no
885
916
  form` is a verdict.
886
917
 
918
+ **`ruled` is a finding the owner has already decided.** The page breaks the rule
919
+ because a decision in `DECISIONS.md` says it should: an icon-only control the
920
+ owner ruled icon-only, a menu the owner put at every phone width. The verdict
921
+ names that decision in `ruling`, exactly as its heading reads, and `verdicts`
922
+ refuses a `ruled` with no ruling or with one the file does not hold. A ruled
923
+ verdict is reported, and counted apart (`ruled=`); it is not a finding, and it
924
+ does not go back to `make`, which could only leave it as it is. On jig-site a
925
+ header critique counted 15 findings, five of them rulings labelled "owner-ruled"
926
+ in prose: every count was inflated, and the real findings were harder to see.
927
+ A rule the page breaks with no decision behind it is a `finding`, however sure
928
+ you are the owner would agree.
929
+
887
930
  Searching the corpus is how a *builder* works, and it is the right method there:
888
931
  it finds the rules you can already name. It cannot find the rule for the mistake
889
932
  you do not know you are making, which is the only kind of rule worth writing
@@ -945,7 +988,8 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
945
988
  "verdicts": [
946
989
  { "id": "D-115", "verdict": "ok", "reason": "no sideways scroll at 360, 768, 1280 or 1600" },
947
990
  { "id": "P-14", "verdict": "finding", "reason": "menu button at 360 does not open; aria-expanded never set" },
948
- { "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" }
991
+ { "id": "A-60", "verdict": "n/a", "reason": "A-60 is about competing icons; this page has none" },
992
+ { "id": "E-51", "verdict": "ruled", "ruling": "The theme toggle is icon-only", "reason": "the toggle shows only a sun or a moon; the owner ruled it icon-only" }
949
993
  ]
950
994
  }
951
995
  ```
@@ -1022,7 +1066,9 @@ was not read, requires `P-14` when the spec has navigation, and refuses
1022
1066
  `rendered: true` without artefacts that exist. It ends with a `JIG_VERDICTS:` line.
1023
1067
 
1024
1068
  When it fails, **re-run the arm it names**. Do not edit the file to make it pass, and
1025
- do not report an incomplete arm as having run. A live Haiku run of this command
1069
+ do not report an incomplete arm as having run. If it says the probes were taken
1070
+ on an older page, `{{scripts_path}} verdicts <surface> --reprobe` re-takes them
1071
+ on the page as it is now. A live Haiku run of this command
1026
1072
  before it existed produced invented rule ids, `screen=ran:1` filed as a review,
1027
1073
  `code=ran:97` against 67, `ran:100` against both, and arms that reported to the
1028
1074
  wrong agent entirely. Every one of those was forbidden here in prose. Prose did not
@@ -1111,6 +1157,17 @@ Say what is right as well as what is wrong — two or three things, and why they
1111
1157
  work. A review that only lists faults tells the reader nothing about what to
1112
1158
  preserve while fixing them.
1113
1159
 
1160
+ **Say what happened to the last round's findings.** `verdicts` compares this
1161
+ critique with the one before it in git and prints `Since the critique before
1162
+ this one: <n> fixed, <n> still open, <n> ruled by the owner, <n> new`, with the
1163
+ ids. Put that line in the report, as `verdicts` printed it, above the findings.
1164
+ The owner should not have to set two reports side by side to learn whether the
1165
+ make round worked. The comparison is made after both arms have written, so it
1166
+ tells the arms nothing.
1167
+
1168
+ List `ruled` verdicts after the findings, under their own heading, each with the
1169
+ decision it cites. They are not findings, and they are not hidden either.
1170
+
1114
1171
  Then the attestation:
1115
1172
 
1116
1173
  ```text
@@ -1166,6 +1223,87 @@ Only then suggest `{{command_prefix}}spec` for the next feature. Starting the ne
1166
1223
  with findings open means the second feature is built on top of the first one's
1167
1224
  problems, and nobody goes back for them.
1168
1225
 
1226
+ ## tweak
1227
+
1228
+ **A small change to a page that is built, reviewed and drawn,** one the owner's
1229
+ approved mockup does not show: a word or a sentence, a colour or its contrast, a
1230
+ weight, spacing inside a region, how text wraps, a focus ring. One pass does what
1231
+ the full loop would: record the decision if there is one, bring the spec in line,
1232
+ build it, and re-judge what the change could affect. There is no mockup step,
1233
+ because the drawing stays true, and no full critique, because nothing else moved.
1234
+
1235
+ ### Refuse rather than guess
1236
+
1237
+ - **No confirmed spec for the page, or its `mockup:` is still pending** → this is
1238
+ a page being made, not changed. Run `{{command_prefix}}spec`, `mockup` and
1239
+ `make`.
1240
+ - **No critique record for the page yet** (`.jig/critique/<surface>/`) → nothing
1241
+ has been judged, so nothing can be re-judged. Run `{{command_prefix}}critique`.
1242
+ - **The change adds, removes, reorders or regroups a region, changes the
1243
+ navigation at any size, or would make the drawing wrong** → it is not a tweak.
1244
+ Say so and name the route: `spec`, then `mockup`, then `make`. The gate checks
1245
+ this whatever you decide: the regions under `sizes:` must match the ones the
1246
+ page had at its last critique, and the drawing must be untouched since then.
1247
+ `<surface>` is the spec's file name (`.jig/specs/<surface>.spec.md`), not its
1248
+ `surface:` field.
1249
+ - **It is unclear what the owner wants** → ask one question and stop.
1250
+
1251
+ ### 1. Decide, if it is a decision
1252
+
1253
+ If the change is a ruling of the owner's, or changes one already recorded (a
1254
+ colour for a mark, how versions are written), record it in `DECISIONS.md` the way
1255
+ `decide` does: the owner's words, dated, and what it changes. If the change
1256
+ applies a rule, or answers a finding the rules already settle (a word stranded on
1257
+ its own line, `B-106`), there is nothing to decide; do not invent a decision.
1258
+
1259
+ ### 2. Bring the spec in line, if it disagrees
1260
+
1261
+ If the spec says something the change contradicts, or the page will no longer
1262
+ look as the spec describes, amend those lines and add a dated **Tweak** entry at
1263
+ the end quoting the owner. Their instruction is the confirmation; do not ask for
1264
+ it again. Leave `sizes:` regions and the mockup as they are.
1265
+
1266
+ ### 3. Build it
1267
+
1268
+ As `make` builds: the project's tokens and components, inside the structure the
1269
+ page already has, and only what the change needs. Run `{{scripts_path}} check`,
1270
+ then render and record the page (`{{scripts_path}} probe --run`) at every width
1271
+ it records.
1272
+
1273
+ ### 4. Re-judge what the change could affect
1274
+
1275
+ Write `.jig/critique/<surface>/tweak.json`:
1276
+
1277
+ ```json
1278
+ {
1279
+ "at": "2026-09-26T14:02:00Z",
1280
+ "change": "the owner's words for the change",
1281
+ "ids": ["A-60", "C-19", "The GitHub link is GitHub's own mark"]
1282
+ }
1283
+ ```
1284
+
1285
+ `ids` names every rule and decision the change could move: the ones that judged
1286
+ the thing that changed, not the whole index. A colour change names the contrast
1287
+ and emphasis rules and the decision it applies; a reworded label names the copy
1288
+ rules for labels and the decision about naming.
1289
+
1290
+ Then a reader that did not make the change re-judges exactly those. It is a
1291
+ subagent, run in the foreground, given the page, the ids and each rule's text,
1292
+ and not this conversation or your reasons for the change. For each id it replaces
1293
+ that verdict in `screen.json`, `code.json` or `decisions.json` with its own
1294
+ verdict and reason, adding `"tweak": "<at>"`. It changes nothing else. Then run
1295
+ `{{scripts_path}} verdicts <surface>`.
1296
+
1297
+ The gate holds both ends: it refuses a tweak that changed a verdict it did not
1298
+ name, and one that named a verdict it did not re-judge.
1299
+
1300
+ ### Finish
1301
+
1302
+ Report what changed, in the owner's words; the decision and spec lines you
1303
+ touched, if any; each re-judged verdict and what it says now; and the
1304
+ `JIG_CHECK` line. A re-judged verdict that is a finding goes to the owner: it is
1305
+ fixed by another tweak, or by `make` if it needs more.
1306
+
1169
1307
  ## probe
1170
1308
 
1171
1309
  Run `{{scripts_path}} probe`. It prints one JavaScript expression — the render probe —
@@ -1391,9 +1529,10 @@ The accent appears as a whole field or not at all. A full-bleed section, a solid
1391
1529
  button, a filled active state — never a 2px underline, never a small icon tint,
1392
1530
  never a gradient stop. Its authority comes from arriving in quantity, rarely.
1393
1531
 
1394
- **Why:** thinly spread, it reads as decoration and stops meaning anything. The
1395
- audit test: if a coloured element is smaller than a section band, it is probably
1396
- wrong.
1532
+ **Why:** "Thinly spread, it reads as decoration and stops meaning anything."
1533
+
1534
+ **Why (inferred):** the audit test that follows from it: if a coloured element is
1535
+ smaller than a section band, it is probably wrong.
1397
1536
  ```
1398
1537
 
1399
1538
  A name gives the team something to cite in review. A reason lets a future agent
@@ -1408,23 +1547,30 @@ the same shape and judged against every page:
1408
1547
  A reader leaves knowing which plan fits them and which limit they would hit
1409
1548
  first. Nothing else on the page outranks that.
1410
1549
 
1411
- **Why:** they arrive from the product page already interested; the job is to
1412
- remove the last doubt, not to sell again.
1550
+ **Why:** "They arrive from the product page already interested. The job is to
1551
+ remove the last doubt, not to sell again."
1413
1552
 
1414
1553
  ### When two options conflict
1415
1554
 
1416
1555
  Comprehension wins over persuasion. If a clearer page converts worse, we ship
1417
1556
  the clearer page.
1418
1557
 
1419
- **Why:** the reader is an engineer who has been sold to badly before, and the
1420
- support cost of a wrong-plan signup outweighs the signup.
1558
+ **Why:** "Our reader is an engineer who has been sold to badly before, and a
1559
+ wrong-plan signup costs more in support than it brings in."
1421
1560
  ```
1422
1561
 
1423
- **The reason is the owner's, or it is not written.** Write the `Why:` the user gave,
1424
- in their words or close to them. If they gave none, ask for it. If they still give
1425
- none, write `**Why:** not given` — never a reason you supplied. A live run invented
1426
- reasons for two real reversals; an invented reason is worse than none, because the
1427
- next agent weighs it as the team's and applies the rule where the team never would.
1562
+ **The reason is the owner's, in quotation marks, or it is not written as theirs.**
1563
+ `**Why:**` holds the words the user gave, quoted. If they gave none, ask for it. If
1564
+ they still give none, write `**Why:** not given`. A live run invented reasons for
1565
+ two real reversals; an invented reason is worse than none, because the next agent
1566
+ weighs it as the team's and applies the rule where the team never would.
1567
+
1568
+ What you add to explain a reason, work out from it, or carry over from what the
1569
+ owner said elsewhere goes in its own paragraph, `**Why (inferred):**`, which says it
1570
+ is yours. On jig-site, reasons read "given directly by the owner", followed by
1571
+ sentences the agent had written; a later agent could not tell which half to weigh
1572
+ as the team's. The gate checks each decision this session wrote or changed: its
1573
+ `Why` is quoted, `not given`, or labelled inferred.
1428
1574
 
1429
1575
  **Record what is still open in its own section, at the end:**
1430
1576
 
@@ -1449,7 +1595,8 @@ round by round, and the section of the file it went to — a named decision, its
1449
1595
  `Why:`, or **Unresolved**. An answer that went nowhere is either added or shown to
1450
1596
  the user as left out on purpose, with the reason. Then go the other way: every
1451
1597
  `Why:` in the file must trace to something the user said; delete any that does not,
1452
- and ask. Include this mapping in your message, below the file.
1598
+ and ask. A `Why (inferred):` traces to what it was inferred from; say what that was.
1599
+ Include this mapping in your message, below the file.
1453
1600
 
1454
1601
  Show it and ask the user to confirm it is right before you stop. Leave no
1455
1602
  `[TODO]` markers — the gate treats them as an unwritten file, correctly, because
@@ -68,5 +68,10 @@
68
68
  "description": "Scrutinise what was built against the rules, its spec and its mockup: render it, judge what check cannot, and report by rule id. Needs DECISIONS.md. No CLI — the agent does this.",
69
69
  "argumentHint": "<page, feature or functionality, in your own words>",
70
70
  "status": "agent"
71
+ },
72
+ "tweak": {
73
+ "description": "A small change to a built page that its approved mockup does not show (wording, a colour, a weight, spacing inside a region, wrapping): records the decision if it is one, brings the spec in line, builds it, and re-judges only the verdicts the change could affect. Refuses a change that moves the page's structure. No CLI — the agent does this.",
74
+ "argumentHint": "<the change, in your own words>",
75
+ "status": "agent"
71
76
  }
72
77
  }