jig-ui 0.19.0 → 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,72 @@
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
+
3
70
  ## 0.19.0 (2026-09-27)
4
71
 
5
72
  A new command, `tweak`, for a small change to a built page that its approved
package/dist/index.js CHANGED
@@ -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);
@@ -6917,7 +7140,7 @@ function sizeBlock2(sizes, size) {
6917
7140
  // src/commands/gate.ts
6918
7141
  import { execFileSync as execFileSync4 } from "child_process";
6919
7142
  var MAX_BLOCKS = 3;
6920
- function lastJigCommand(transcriptPath) {
7143
+ function lastJigInvocation(transcriptPath) {
6921
7144
  if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
6922
7145
  let text;
6923
7146
  try {
@@ -6928,10 +7151,20 @@ function lastJigCommand(transcriptPath) {
6928
7151
  let found;
6929
7152
  for (const line of text.split("\n")) {
6930
7153
  if (!line.includes("/jig")) continue;
6931
- const name = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(line);
6932
- const plain = /(?:^|["\s>])\/jig\s+([a-z]+)/.exec(line);
6933
- const arg = (name?.[1] ?? plain?.[1] ?? "").trim().split(/\s+/)[0];
6934
- 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
+ }
6935
7168
  }
6936
7169
  return found;
6937
7170
  }
@@ -6986,18 +7219,27 @@ function verdictsMtime(dir) {
6986
7219
  }
6987
7220
  return newest;
6988
7221
  }
6989
- function surfacesInPlay(root, command, transcriptPath) {
7222
+ function surfacesInPlay(root, command, transcriptPath, surface) {
6990
7223
  const critiqueDir = join39(root, ".jig", "critique");
6991
7224
  if (!existsSync24(critiqueDir)) return [];
6992
7225
  const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
6993
7226
  const start = sessionStart(transcriptPath);
6994
7227
  if (start === void 0) return all;
6995
- const current = command === "critique" || command === "tweak" ? /^\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;
6996
7229
  return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
6997
7230
  }
6998
- 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) {
6999
7241
  const problems = [];
7000
- const spec = newestSpec(root);
7242
+ const spec = specFor(root, surface);
7001
7243
  if (command === "decide") {
7002
7244
  const found = decisionsFile(root);
7003
7245
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
@@ -7007,6 +7249,7 @@ function commandProblems(root, command) {
7007
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.`");
7008
7250
  }
7009
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}`));
7010
7253
  }
7011
7254
  }
7012
7255
  if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
@@ -7029,16 +7272,43 @@ function commandProblems(root, command) {
7029
7272
  if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7030
7273
  }
7031
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
+ }
7032
7302
  if (command === "critique") {
7033
7303
  const dir = join39(root, ".jig", "critique");
7034
7304
  const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
7035
- for (const surface of surfaces) {
7036
- const v = verifyVerdicts({ projectRoot: root, surface });
7305
+ for (const surface2 of surfaces) {
7306
+ const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7037
7307
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
7038
- 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.`);
7039
7309
  }
7040
7310
  if (v.ok && v.screen.state === "skipped") {
7041
- 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.`);
7042
7312
  }
7043
7313
  }
7044
7314
  if (surfaces.length === 0) {
@@ -7054,7 +7324,7 @@ function tweakProblems(root, spec) {
7054
7324
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7055
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\`.`);
7056
7326
  else problems.push(...structureSinceApproval(root, spec, front));
7057
- const surface = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? spec.slug;
7327
+ const surface = spec.slug;
7058
7328
  const record = readTweak(join39(root, ".jig", "critique", surface));
7059
7329
  if (!record) {
7060
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\`).`);
@@ -7072,29 +7342,35 @@ function tweakProblems(root, spec) {
7072
7342
  }
7073
7343
  function structureSinceApproval(root, spec, front) {
7074
7344
  const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7075
- let approvedIn;
7345
+ let baseline;
7346
+ let judged = true;
7076
7347
  try {
7077
- approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:", "--", spec.path]).trim();
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
+ }
7078
7353
  } catch {
7079
7354
  return [];
7080
7355
  }
7081
- if (!approvedIn) return [];
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)})`;
7082
7358
  const problems = [];
7083
7359
  let thenBody = "";
7084
7360
  try {
7085
- thenBody = git(["show", `${approvedIn}:./${spec.path}`]);
7361
+ thenBody = git(["show", `${baseline}:./${spec.path}`]);
7086
7362
  } catch {
7087
7363
  return [];
7088
7364
  }
7089
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 ?? "")]));
7090
7366
  if (shape(thenBody) !== shape(spec.body)) {
7091
- problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones approved with the mockup (${approvedIn.slice(0, 7)}). A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
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\`.`);
7092
7368
  }
7093
7369
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7094
7370
  if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
7095
7371
  try {
7096
- if (git(["show", `${approvedIn}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
7097
- problems.push(`${at} has changed since the owner approved it (${approvedIn.slice(0, 7)}). A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
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\`.`);
7098
7374
  }
7099
7375
  } catch {
7100
7376
  }
@@ -7110,7 +7386,7 @@ function readTweak(dir) {
7110
7386
  }
7111
7387
  }
7112
7388
  function surfacePage(projectRoot, surface) {
7113
- const spec = newestSpec(projectRoot);
7389
+ const spec = specFor(projectRoot, surface);
7114
7390
  const front = spec?.body.split(/^---\s*$/m)[1] ?? "";
7115
7391
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7116
7392
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
@@ -7124,9 +7400,10 @@ function gate(opts) {
7124
7400
  if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
7125
7401
  return { block: false, reason: "" };
7126
7402
  }
7127
- const command = lastJigCommand(opts.input.transcript_path);
7403
+ const invocation = lastJigInvocation(opts.input.transcript_path);
7404
+ const command = invocation?.command;
7128
7405
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7129
- 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}`) : [];
7130
7407
  const selection = selectFiles(root, false);
7131
7408
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7132
7409
  if (changedUi) {
@@ -7147,10 +7424,10 @@ function gate(opts) {
7147
7424
  );
7148
7425
  }
7149
7426
  }
7150
- 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)));
7151
7428
  const critiqueDir = join39(root, ".jig", "critique");
7152
7429
  if (existsSync24(critiqueDir)) {
7153
- 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)) {
7154
7431
  const dir = join39(critiqueDir, surface);
7155
7432
  if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
7156
7433
  const v = verifyVerdicts({ projectRoot: root, surface });
@@ -7499,19 +7776,42 @@ program.command("update").description("Update vendored Jig rules, skipping files
7499
7776
  );
7500
7777
  for (const f of result.updated) console.log(` ~ ${f}`);
7501
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.");
7502
7784
  } catch (err) {
7503
7785
  console.error(err.message);
7504
7786
  process.exit(1);
7505
7787
  }
7506
7788
  });
7507
- 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) => {
7508
7790
  const projectRoot = findProjectRoot(process.cwd());
7509
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
+ }
7510
7802
  const result = verifyVerdicts({ projectRoot, surface });
7511
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
+ }
7512
7807
  if (result.ok) console.log(` Every rule in both passes has a verdict.`);
7513
7808
  const since = result.decisions.since ?? [];
7514
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
+ }
7515
7815
  console.log(` ${result.line}`);
7516
7816
  process.exit(result.ok ? 0 : 1);
7517
7817
  } catch (err) {
@@ -7609,7 +7909,7 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
7609
7909
  try {
7610
7910
  const projectRoot = findProjectRoot(cwd);
7611
7911
  for (const surface of critiquedSurfaces(projectRoot)) {
7612
- const page = surfacePage(projectRoot, surface);
7912
+ const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
7613
7913
  if (!page) continue;
7614
7914
  try {
7615
7915
  await ensureProbes({ projectRoot, surface, page });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.19.0",
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`).
@@ -464,6 +464,14 @@ spec is not a weaker spec — it is not a spec, and `make` will refuse it.
464
464
  If the user changes something, revise and ask again. The run is incomplete until
465
465
  they confirm.
466
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
+
467
475
  ## mockup
468
476
 
469
477
  **Low-fidelity design.** Draw the confirmed spec in grayscale, at every size it
@@ -724,6 +732,16 @@ between them yourself.
724
732
 
725
733
  If `mockup: skipped`, build from the spec alone.
726
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
+
727
745
  Build V1 only. Nothing in `later:`, however little extra it looks.
728
746
 
729
747
  Build the **phone** composition first, then add what `tablet` and `desktop`
@@ -877,16 +895,38 @@ A and C are kept apart for a narrower reason: an arm that has seen the render
877
895
  stops reading the source and starts confirming the picture. They answer to
878
896
  different evidence and must not share it.
879
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
+
880
908
  ### 1a. Arms A and C walk the index. They do not search it.
881
909
 
882
910
  **This is not a style preference. It is the difference between covering the
883
911
  corpus and sampling it.**
884
912
 
885
913
  Load `rules.index.json`, filter to the pass the arm owns, and return a verdict
886
- for **every id in that list**: `ok`, `finding`, or `n/a` with one line of
887
- 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
888
916
  form` is a verdict.
889
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
+
890
930
  Searching the corpus is how a *builder* works, and it is the right method there:
891
931
  it finds the rules you can already name. It cannot find the rule for the mistake
892
932
  you do not know you are making, which is the only kind of rule worth writing
@@ -948,7 +988,8 @@ Each reader arm writes its own file. The arm reports back only that it wrote it.
948
988
  "verdicts": [
949
989
  { "id": "D-115", "verdict": "ok", "reason": "no sideways scroll at 360, 768, 1280 or 1600" },
950
990
  { "id": "P-14", "verdict": "finding", "reason": "menu button at 360 does not open; aria-expanded never set" },
951
- { "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" }
952
993
  ]
953
994
  }
954
995
  ```
@@ -1025,7 +1066,9 @@ was not read, requires `P-14` when the spec has navigation, and refuses
1025
1066
  `rendered: true` without artefacts that exist. It ends with a `JIG_VERDICTS:` line.
1026
1067
 
1027
1068
  When it fails, **re-run the arm it names**. Do not edit the file to make it pass, and
1028
- 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
1029
1072
  before it existed produced invented rule ids, `screen=ran:1` filed as a review,
1030
1073
  `code=ran:97` against 67, `ran:100` against both, and arms that reported to the
1031
1074
  wrong agent entirely. Every one of those was forbidden here in prose. Prose did not
@@ -1114,6 +1157,17 @@ Say what is right as well as what is wrong — two or three things, and why they
1114
1157
  work. A review that only lists faults tells the reader nothing about what to
1115
1158
  preserve while fixing them.
1116
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
+
1117
1171
  Then the attestation:
1118
1172
 
1119
1173
  ```text
@@ -1188,8 +1242,10 @@ because the drawing stays true, and no full critique, because nothing else moved
1188
1242
  - **The change adds, removes, reorders or regroups a region, changes the
1189
1243
  navigation at any size, or would make the drawing wrong** → it is not a tweak.
1190
1244
  Say so and name the route: `spec`, then `mockup`, then `make`. The gate checks
1191
- this whatever you decide: the regions under `sizes:` must match the ones
1192
- approved with the mockup, and the drawing must be untouched.
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.
1193
1249
  - **It is unclear what the owner wants** → ask one question and stop.
1194
1250
 
1195
1251
  ### 1. Decide, if it is a decision
@@ -1473,9 +1529,10 @@ The accent appears as a whole field or not at all. A full-bleed section, a solid
1473
1529
  button, a filled active state — never a 2px underline, never a small icon tint,
1474
1530
  never a gradient stop. Its authority comes from arriving in quantity, rarely.
1475
1531
 
1476
- **Why:** thinly spread, it reads as decoration and stops meaning anything. The
1477
- audit test: if a coloured element is smaller than a section band, it is probably
1478
- 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.
1479
1536
  ```
1480
1537
 
1481
1538
  A name gives the team something to cite in review. A reason lets a future agent
@@ -1490,23 +1547,30 @@ the same shape and judged against every page:
1490
1547
  A reader leaves knowing which plan fits them and which limit they would hit
1491
1548
  first. Nothing else on the page outranks that.
1492
1549
 
1493
- **Why:** they arrive from the product page already interested; the job is to
1494
- 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."
1495
1552
 
1496
1553
  ### When two options conflict
1497
1554
 
1498
1555
  Comprehension wins over persuasion. If a clearer page converts worse, we ship
1499
1556
  the clearer page.
1500
1557
 
1501
- **Why:** the reader is an engineer who has been sold to badly before, and the
1502
- 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."
1503
1560
  ```
1504
1561
 
1505
- **The reason is the owner's, or it is not written.** Write the `Why:` the user gave,
1506
- in their words or close to them. If they gave none, ask for it. If they still give
1507
- none, write `**Why:** not given` — never a reason you supplied. A live run invented
1508
- reasons for two real reversals; an invented reason is worse than none, because the
1509
- 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.
1510
1574
 
1511
1575
  **Record what is still open in its own section, at the end:**
1512
1576
 
@@ -1531,7 +1595,8 @@ round by round, and the section of the file it went to — a named decision, its
1531
1595
  `Why:`, or **Unresolved**. An answer that went nowhere is either added or shown to
1532
1596
  the user as left out on purpose, with the reason. Then go the other way: every
1533
1597
  `Why:` in the file must trace to something the user said; delete any that does not,
1534
- 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.
1535
1600
 
1536
1601
  Show it and ask the user to confirm it is right before you stop. Leave no
1537
1602
  `[TODO]` markers — the gate treats them as an unwritten file, correctly, because