jig-ui 0.20.1 → 0.21.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,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0 (2026-09-28)
4
+
5
+ Dev builds, so a fix is tried on a real project before it ships, and the gate
6
+ fixes that trial on jig-site turned up.
7
+
8
+ ### Added
9
+
10
+ - **Dev builds.** A version carrying `-dev` (`0.21.0-dev.1`) is a local build
11
+ of a fix, tried on a real project before it is released. npm has no such
12
+ version, so its skill, its commands and its Stop hook run the `jig` on PATH
13
+ rather than `npx jig-ui@<version>`; a hook that could not run would let every
14
+ agent stop unchecked. `scripts/pack-dev.mjs` packs one. Installing a release
15
+ afterwards puts the `npx` hook back in place of the dev one.
16
+
17
+ ### Fixed
18
+
19
+ - **The Stop hook refreshes only the probes it judges.** It re-rendered every
20
+ critiqued page on every stop, so a link added to jig-site's header left sixty
21
+ probe files changed on pages no one was working on, and three sessions
22
+ committed them. A page's probes are refreshed when its own critique or tweak
23
+ runs.
24
+ - **A mockup is checked before it goes to the owner.** The pause for approval
25
+ skipped the drawing check, so on jig-site two mockup sessions put a drawing
26
+ the check refused to the owner, reported the gate's blocks as "waiting on
27
+ owner review", and the owner approved a drawing the gate then refused `make`
28
+ for.
29
+ - **A region label may say its name with spaces.** "on this page" now labels the
30
+ spec's `on-this-page`; hyphens, underscores and spaces compare as one.
31
+ - **A comment after a size is a comment.** `phone: # judged at 360px` over a
32
+ full composition was refused as a one-line size.
33
+ - **A critique's report names every finding its verdict files hold.** jig-site's
34
+ Guide critique held seven and listed six; make reads the report. Checked
35
+ where the project keeps a `REPORT.md` beside the verdicts.
36
+ - **A session cannot change Jig's mode file.** A jig-site make round narrowed
37
+ `--size-rail` in `mode.editorial.css` to make three columns fit; `update`
38
+ owns that file, and a project's own values go in its brand file or stylesheet.
39
+ - **A decide amendment answers for its own reason.** Like a tweak's, only the
40
+ `**Why…:**` paragraphs it adds must quote the owner; three jig-site amendments
41
+ had to relabel an earlier round's reason before the gate let them stop.
42
+ - **A finding is fixed only where the source it cited changed.** `verdicts`
43
+ told jig-site's fourth home critique that seven findings were fixed; four had
44
+ flipped because its readers read the same unchanged lines differently. A
45
+ finding judged ok is now fixed when a line it cited (`file:line`) changed, or
46
+ its file when it cited no line, or any source when it cited nothing. The rest
47
+ are reported as judged ok though the lines they cited did not change: a
48
+ different reading, or a fix made where the finding did not point.
49
+ - **The gate asks for the verdict lock to be committed.** It writes
50
+ `verdicts.lock` when a critique or tweak stops, after the agent has committed,
51
+ so every such session on jig-site left it behind. When the verdicts are
52
+ committed and the lock is not, it stops the session once to commit it.
53
+ - **A tweak's decision quotes the owner's recorded words.** A jig-site tweak
54
+ shown a screenshot wrote an exception to `E-51` "given directly by the owner
55
+ … by reference rather than words". A tweak's new `**Why:**` now meets
56
+ `decide`'s rule (the owner's words in quotation marks, `not given`, or
57
+ `**Why (inferred):**`), and its quotations must be in tweak.json's `change`.
58
+ A reason `decide` wrote earlier is not the tweak's to answer for.
59
+ - **`spec`, `mockup` and `critique` write under `.jig/` only.** On jig-site a
60
+ critique swapped the rule its page demonstrates to get past a block, then
61
+ re-judged the page it had changed, and a mockup session wrote the site's
62
+ stylesheet. The gate stops either.
63
+ - **An approved drawing changed afterwards goes back to pending.** The session
64
+ that recorded a jig-site approval then redrew frames and moved a switch.
65
+ - **Quoted tool output is not the page's copy.** The probe's em-dash scan skips
66
+ `<pre>`, `<code>`, `<samp>` and `<kbd>`, as it skips quotations: jig-site's
67
+ home page quotes `jig check`'s real output, whose wording has one. Probe
68
+ version 9.
69
+ - **A drawing's size frames are the class `frame`, not any class containing
70
+ it.** jig-site's home drawing captioned its rule specimens `spec-frame`, and
71
+ the gate split every size frame at each one and reported regions missing.
72
+
3
73
  ## 0.20.1 (2026-09-27)
4
74
 
5
75
  `jig gate` run by hand no longer tells an agent that a passing tweak failed.
package/dist/index.js CHANGED
@@ -40,6 +40,14 @@ function assetRoot(startDir = getPackageRoot()) {
40
40
  function isPublishedBuild(packageRoot2) {
41
41
  return packageRoot2.split(/[\\/]/).includes("node_modules");
42
42
  }
43
+ function isDevVersion(version2) {
44
+ return !!version2 && /-dev(\.|$)/.test(version2);
45
+ }
46
+ function cliInvocation(version2) {
47
+ if (!version2) return "npx jig-ui";
48
+ return isDevVersion(version2) ? "jig" : `npx jig-ui@${version2}`;
49
+ }
50
+ var JIG_GATE_HOOK = /(?:\bjig-ui@[^\s"]+|^jig) gate\b/;
43
51
 
44
52
  // src/commands/install.ts
45
53
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
@@ -578,7 +586,7 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
578
586
  );
579
587
  return render(template, {
580
588
  command_prefix: "/jig ",
581
- scripts_path: version2 ? `npx jig-ui@${version2}` : "npx jig-ui",
589
+ scripts_path: cliInvocation(version2),
582
590
  // `update` must NOT carry the pin. Pinned, it refreshes to the version
583
591
  // already installed — reporting "Updated Jig → <same version>" for a no-op
584
592
  // — so a reader following the skill could never upgrade.
@@ -597,7 +605,7 @@ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
597
605
  const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
598
606
  if (!existsSync5(templatePath)) return { body: "", subcommands };
599
607
  const template = readFileSync4(templatePath, "utf8");
600
- const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath).replace(/\{\{config_file\}\}/g, "jig.config.json");
608
+ const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, cliInvocation(version2)).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath).replace(/\{\{config_file\}\}/g, "jig.config.json");
601
609
  const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
602
610
  if (unresolved.length > 0) {
603
611
  throw new Error(
@@ -724,7 +732,8 @@ function install(opts) {
724
732
  function hasStopHook(projectRoot) {
725
733
  try {
726
734
  const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
727
- return /\bjig-ui@[^\s"]+ gate\b/.test(settings);
735
+ const hooks = JSON.parse(settings).hooks?.Stop ?? [];
736
+ return hooks.some((group) => (group.hooks ?? []).some((h) => JIG_GATE_HOOK.test(h.command ?? "")));
728
737
  } catch {
729
738
  return false;
730
739
  }
@@ -739,9 +748,9 @@ function installStopHook(projectRoot, version2) {
739
748
  return false;
740
749
  }
741
750
  }
742
- const command = `npx --yes jig-ui@${version2} gate`;
751
+ const command = isDevVersion(version2) ? "jig gate" : `npx --yes jig-ui@${version2} gate`;
743
752
  const hooks = settings.hooks ?? {};
744
- const isJig = (h) => /\bjig-ui@[^\s]+ gate\b/.test(h.command ?? "");
753
+ const isJig = (h) => JIG_GATE_HOOK.test(h.command ?? "");
745
754
  const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
746
755
  stop.push({ hooks: [{ type: "command", command }] });
747
756
  settings.hooks = { ...hooks, Stop: stop };
@@ -5771,7 +5780,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
5771
5780
  import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5772
5781
 
5773
5782
  // src/probe/script.ts
5774
- var PROBE_VERSION = 8;
5783
+ var PROBE_VERSION = 9;
5775
5784
  var PROBE_SCRIPT = `(async () => {
5776
5785
  const doc = document.documentElement;
5777
5786
  // checkVisibility also sees content a closed <details> hides: Chromium hides
@@ -5786,9 +5795,12 @@ var PROBE_SCRIPT = `(async () => {
5786
5795
  const text = document.body.innerText || '';
5787
5796
  // I-118: a verbatim quotation keeps its own punctuation. Text marked as a
5788
5797
  // quotation is the source's words, so it is taken out before the dash scan;
5789
- // the page's own copy around it still counts.
5798
+ // the page's own copy around it still counts. So is a tool's output or a
5799
+ // command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
5800
+ // quotes \`jig check\`'s real output, whose own wording has an em dash, and
5801
+ // the probe blocked a page for words it had rightly not rewritten.
5790
5802
  let ownText = text;
5791
- for (const q of document.querySelectorAll('blockquote, q')) {
5803
+ for (const q of document.querySelectorAll('blockquote, q, pre, code, samp, kbd')) {
5792
5804
  const quoted = (q.innerText || '').trim();
5793
5805
  if (quoted) ownText = ownText.split(quoted).join('\\n');
5794
5806
  }
@@ -6557,12 +6569,22 @@ function decisionSections(body) {
6557
6569
  close();
6558
6570
  return sections;
6559
6571
  }
6560
- function unsourcedReasons(current, before) {
6572
+ function changedWhys(current, before, newOnly = false) {
6561
6573
  const then = decisionSections(before);
6562
- const problems = [];
6574
+ const paragraphs = (text) => text.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => /^\*\*Why\b[^*]*:\*\*/.test(p));
6575
+ const out = [];
6563
6576
  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));
6577
+ const old = then.get(name);
6578
+ if (old === text) continue;
6579
+ const kept = newOnly && old !== void 0 ? new Set(paragraphs(old)) : /* @__PURE__ */ new Set();
6580
+ const whys = paragraphs(text).filter((p) => !kept.has(p));
6581
+ if (whys.length) out.push([name, whys]);
6582
+ }
6583
+ return out;
6584
+ }
6585
+ function unsourcedReasons(current, before, opts = {}) {
6586
+ const problems = [];
6587
+ for (const [name, whys] of changedWhys(current, before, opts.newWhysOnly)) {
6566
6588
  for (const why of whys) {
6567
6589
  const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6568
6590
  const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
@@ -6575,6 +6597,23 @@ function unsourcedReasons(current, before) {
6575
6597
  }
6576
6598
  return problems;
6577
6599
  }
6600
+ function quotesNotFrom(current, before, words) {
6601
+ const norm = (t) => t.toLowerCase().replace(/[‘’]/g, "'").replace(/\s+/g, " ").trim();
6602
+ const said = norm(words);
6603
+ const problems = [];
6604
+ for (const [name, whys] of changedWhys(current, before, true)) {
6605
+ for (const why of whys) {
6606
+ const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6607
+ if (/inferred/i.test(label)) continue;
6608
+ const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => q.split(/…|\.\.\./).map((part) => norm(part).replace(/^[\s.,;:]+|[\s.,;:!?]+$/g, "")).filter(Boolean).some((part) => !said.includes(part)));
6609
+ if (missing.length) {
6610
+ problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which the owner's words in tweak.json (\`change\`) do not say. Quote the owner as tweak.json records them, or put your reading under \`**Why (inferred):**\`.`);
6611
+ break;
6612
+ }
6613
+ }
6614
+ }
6615
+ return problems;
6616
+ }
6578
6617
 
6579
6618
  // src/check/spec-shape.ts
6580
6619
  import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
@@ -6615,7 +6654,7 @@ function specProblems(spec) {
6615
6654
  problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6616
6655
  continue;
6617
6656
  }
6618
- if (line[1].trim()) {
6657
+ if (line[1].replace(/(^|\s)#.*$/, "").trim()) {
6619
6658
  problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
6620
6659
  continue;
6621
6660
  }
@@ -6918,10 +6957,12 @@ function previousFindings(projectRoot, dir) {
6918
6957
  const files = ["screen.json", "code.json", "decisions.json"];
6919
6958
  const paths = files.map((f) => `${rel}/${f}`);
6920
6959
  let commit;
6960
+ let target;
6921
6961
  try {
6922
6962
  const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
6923
6963
  const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
6924
6964
  commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
6965
+ target = dirty ? void 0 : touched[0];
6925
6966
  } catch {
6926
6967
  return void 0;
6927
6968
  }
@@ -6942,7 +6983,7 @@ function previousFindings(projectRoot, dir) {
6942
6983
  }
6943
6984
  for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
6944
6985
  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);
6986
+ if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), { verdict: v.verdict, reason: String(v.reason ?? "") });
6946
6987
  }
6947
6988
  return out;
6948
6989
  };
@@ -6952,17 +6993,64 @@ function previousFindings(projectRoot, dir) {
6952
6993
  for (const [k, v] of read(f, commit)) before.set(k, v);
6953
6994
  for (const [k, v] of read(f)) now.set(k, v);
6954
6995
  }
6955
- const result = { commit: commit.slice(0, 7), fixed: [], open: [], added: [], ruled: [] };
6996
+ const changed = sourceChanges(git, commit, target);
6997
+ const result = { commit: commit.slice(0, 7), fixed: [], unchanged: [], open: [], added: [], ruled: [] };
6956
6998
  for (const [k, v] of before) {
6957
- if (v !== "finding") continue;
6958
- const after = now.get(k);
6999
+ if (v.verdict !== "finding") continue;
7000
+ const after = now.get(k)?.verdict;
6959
7001
  if (after === "finding") result.open.push(k);
6960
7002
  else if (after === "ruled") result.ruled.push(k);
6961
- else if (after === "ok" || after === "n/a") result.fixed.push(k);
7003
+ else if (after === "ok" || after === "n/a") (changed(v.reason) ? result.fixed : result.unchanged).push(k);
6962
7004
  }
6963
- for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
7005
+ for (const [k, v] of now) if (v.verdict === "finding" && before.get(k)?.verdict !== "finding") result.added.push(k);
6964
7006
  return result;
6965
7007
  }
7008
+ var CITATION = /([\w@.\-[\]/]*[\w\]-]\.[a-z][a-z0-9]{0,5})(?::(\d+(?:-\d+)?(?:,\s*\d+(?:-\d+)?)*))?/gi;
7009
+ function sourceChanges(git, from, to) {
7010
+ const range = to ? [from, to] : [from];
7011
+ let tracked = [];
7012
+ let changedFiles = /* @__PURE__ */ new Set();
7013
+ try {
7014
+ tracked = git(["ls-tree", "-r", "--name-only", from]).split("\n").filter((f) => f && !f.startsWith(".jig/"));
7015
+ changedFiles = new Set(git(["diff", "--name-only", ...range, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean));
7016
+ } catch {
7017
+ }
7018
+ const hunks = /* @__PURE__ */ new Map();
7019
+ const oldRanges = (file) => {
7020
+ let out = hunks.get(file);
7021
+ if (out) return out;
7022
+ out = [];
7023
+ try {
7024
+ for (const m of git(["diff", "-U0", ...range, "--", file]).matchAll(/^@@ -(\d+)(?:,(\d+))? /gm)) {
7025
+ const start = Number(m[1]);
7026
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
7027
+ out.push(count === 0 ? [start, start + 1] : [start, start + count - 1]);
7028
+ }
7029
+ } catch {
7030
+ }
7031
+ hunks.set(file, out);
7032
+ return out;
7033
+ };
7034
+ return (reason) => {
7035
+ let cited = false;
7036
+ for (const m of reason.matchAll(CITATION)) {
7037
+ const path = m[1].replace(/^\.\//, "");
7038
+ const matches2 = tracked.filter((f) => f === path || f.endsWith(`/${path}`));
7039
+ if (!matches2.length) continue;
7040
+ cited = true;
7041
+ const lines = (m[2] ?? "").split(",").map((r) => r.trim()).filter(Boolean).map((r) => {
7042
+ const [a, b] = r.split("-").map(Number);
7043
+ return [a, b ?? a];
7044
+ });
7045
+ for (const file of matches2) {
7046
+ if (!changedFiles.has(file)) continue;
7047
+ if (!lines.length) return true;
7048
+ if (oldRanges(file).some(([hs, he]) => lines.some(([s, e]) => hs <= e && he >= s))) return true;
7049
+ }
7050
+ }
7051
+ return cited ? false : changedFiles.size > 0;
7052
+ };
7053
+ }
6966
7054
 
6967
7055
  // src/commands/gate.ts
6968
7056
  import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
@@ -7053,7 +7141,7 @@ function specSwitches(specBody) {
7053
7141
  return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^(--)?breakpoint-/, "")).filter(Boolean);
7054
7142
  }
7055
7143
  function readFrames(html) {
7056
- const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bframe\b[^"']*["'][^>]*>/gi)];
7144
+ const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["']([^"']*)["'][^>]*>/gi)].filter((m) => m[1].split(/\s+/).includes("frame"));
7057
7145
  return opens.map((open, i) => {
7058
7146
  const tag = open[0];
7059
7147
  const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
@@ -7119,7 +7207,9 @@ function regionDrawn(labels, region) {
7119
7207
  const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
7120
7208
  return found(region.name) || !!region.parts && region.parts.every(found);
7121
7209
  }
7122
- function labelMatches(label, name) {
7210
+ function labelMatches(rawLabel, rawName) {
7211
+ const label = rawLabel.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
7212
+ const name = rawName.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
7123
7213
  return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
7124
7214
  }
7125
7215
  function normalise(text) {
@@ -7228,6 +7318,10 @@ function surfacesInPlay(root, command, transcriptPath, surface) {
7228
7318
  const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
7229
7319
  return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
7230
7320
  }
7321
+ function surfacesToProbe(root, input) {
7322
+ const invocation = lastJigInvocation(input.transcript_path);
7323
+ return surfacesInPlay(root, invocation?.command, input.transcript_path, invocation?.surface);
7324
+ }
7231
7325
  function fileAtSessionStart(root, path, start) {
7232
7326
  const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7233
7327
  try {
@@ -7237,6 +7331,35 @@ function fileAtSessionStart(root, path, start) {
7237
7331
  return "";
7238
7332
  }
7239
7333
  }
7334
+ function changedOutsideRecords(root, start) {
7335
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7336
+ try {
7337
+ const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
7338
+ if (!base) return [];
7339
+ const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7340
+ const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7341
+ try {
7342
+ return start === void 0 || statSync5(join39(root, f)).mtimeMs >= start - 1e3;
7343
+ } catch {
7344
+ return false;
7345
+ }
7346
+ });
7347
+ return [.../* @__PURE__ */ new Set([...changed, ...created])].sort();
7348
+ } catch {
7349
+ return [];
7350
+ }
7351
+ }
7352
+ function drawingChangedAfterApproval(root, specPath, at) {
7353
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7354
+ try {
7355
+ const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7356
+ if (!approvedIn) return void 0;
7357
+ const then = git(["show", `${approvedIn}:./${at}`]);
7358
+ return then !== readFileSync32(join39(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7359
+ } catch {
7360
+ return void 0;
7361
+ }
7362
+ }
7240
7363
  function commandProblems(root, command, surface, start) {
7241
7364
  const problems = [];
7242
7365
  const spec = specFor(root, surface);
@@ -7249,7 +7372,7 @@ function commandProblems(root, command, surface, start) {
7249
7372
  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.`");
7250
7373
  }
7251
7374
  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}`));
7375
+ problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start), { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
7253
7376
  }
7254
7377
  }
7255
7378
  if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
@@ -7285,6 +7408,25 @@ function commandProblems(root, command, surface, start) {
7285
7408
  }
7286
7409
  }
7287
7410
  }
7411
+ if (command === "spec" || command === "mockup" || command === "critique") {
7412
+ const outside = changedOutsideRecords(root, start);
7413
+ if (outside.length) {
7414
+ problems.push(
7415
+ `${command} writes under .jig/ only, and this session changed ${outside.slice(0, 6).join(", ")}${outside.length > 6 ? ` and ${outside.length - 6} more` : ""}. Put them back as they were when the session began, and hand the change to \`/jig make\` (or \`/jig tweak\` for a small one to a built page). ` + (command === "critique" ? "A critique that changes the page it judges is grading its own work; report the finding instead." : "The build is make's.")
7416
+ );
7417
+ }
7418
+ }
7419
+ if (command === "mockup" && spec) {
7420
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7421
+ const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7422
+ const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7423
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
7424
+ const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7425
+ if (changedAfter) {
7426
+ problems.push(`${at} has changed since the owner approved it (${changedAfter}). An approval is of the drawing as the owner saw it: set \`mockup: pending\` in ${spec.path} and ask again.`);
7427
+ }
7428
+ }
7429
+ }
7288
7430
  if (command === "make") {
7289
7431
  const found = decisionsFile(root);
7290
7432
  if (found) {
@@ -7299,6 +7441,36 @@ function commandProblems(root, command, surface, start) {
7299
7441
  }
7300
7442
  }
7301
7443
  }
7444
+ if (command === "tweak" && spec) {
7445
+ const found = decisionsFile(root);
7446
+ if (found) {
7447
+ let now = "";
7448
+ try {
7449
+ now = readFileSync32(join39(root, found), "utf8");
7450
+ } catch {
7451
+ }
7452
+ const then = fileAtSessionStart(root, found, start);
7453
+ if (now && now !== then) {
7454
+ problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7455
+ const change = readTweak(join39(root, ".jig", "critique", spec.slug))?.change;
7456
+ if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7457
+ }
7458
+ }
7459
+ }
7460
+ if (command !== "update" && command !== "init" && command !== "install") {
7461
+ for (const file of modeFiles(root)) {
7462
+ let now = "";
7463
+ try {
7464
+ now = readFileSync32(join39(root, file), "utf8");
7465
+ } catch {
7466
+ continue;
7467
+ }
7468
+ const then = fileAtSessionStart(root, file, start);
7469
+ if (then && now !== then) {
7470
+ problems.push(`${file} changed in this session. A mode file is Jig's, refreshed by \`update\`; a value this project needs of its own goes in its brand file or its own stylesheet. Restore it (\`git checkout -- ${file}\`, or \`git show <commit>:${file}\` if the change is committed).`);
7471
+ }
7472
+ }
7473
+ }
7302
7474
  if (command === "critique") {
7303
7475
  const dir = join39(root, ".jig", "critique");
7304
7476
  const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
@@ -7311,12 +7483,56 @@ function commandProblems(root, command, surface, start) {
7311
7483
  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.`);
7312
7484
  }
7313
7485
  }
7486
+ const own = specFor(root, surface)?.slug;
7487
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join39(dir, own), own));
7314
7488
  if (surfaces.length === 0) {
7315
7489
  problems.push("critique wrote no verdict files. Each reader arm writes .jig/critique/<surface>/screen.json or code.json, and `jig verdicts <surface>` \u2014 not your own count \u2014 decides whether the review is complete. A report without them is not a review.");
7316
7490
  }
7317
7491
  }
7318
7492
  return problems;
7319
7493
  }
7494
+ function drawingBeforeAsking(root, surface) {
7495
+ const spec = specFor(root, surface);
7496
+ if (!spec) return [];
7497
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7498
+ const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7499
+ const at = named || `.jig/mockups/${spec.slug}.html`;
7500
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync24(join39(root, at))) return [];
7501
+ const found = mockupDrawingProblems(root, spec.body, at);
7502
+ return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
7503
+ }
7504
+ function modeFiles(root) {
7505
+ try {
7506
+ const state = JSON.parse(readFileSync32(join39(root, ".jig", "state.json"), "utf8"));
7507
+ return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
7508
+ } catch {
7509
+ return [];
7510
+ }
7511
+ }
7512
+ function reportOmissions(dir, surface) {
7513
+ let report2;
7514
+ try {
7515
+ report2 = readFileSync32(join39(dir, "REPORT.md"), "utf8").toLowerCase();
7516
+ } catch {
7517
+ return [];
7518
+ }
7519
+ const missing = [];
7520
+ for (const f of VERDICT_FILES) {
7521
+ let file;
7522
+ try {
7523
+ file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
7524
+ } catch {
7525
+ continue;
7526
+ }
7527
+ for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
7528
+ if (v.verdict !== "finding") continue;
7529
+ const key = String(v.id ?? v.decision ?? "").trim();
7530
+ if (key && !report2.includes(key.toLowerCase())) missing.push(v.id ? key : `"${key}"`);
7531
+ }
7532
+ }
7533
+ if (!missing.length) return [];
7534
+ return [`.jig/critique/${surface}/REPORT.md leaves out ${missing.length === 1 ? "a finding" : `${missing.length} findings`} the verdict files hold: ${missing.slice(0, 8).join(", ")}. The report is what the owner and \`make\` read; name every finding in it, by id (a decision by its name).`];
7535
+ }
7320
7536
  function tweakProblems(root, spec) {
7321
7537
  const problems = [];
7322
7538
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
@@ -7404,6 +7620,7 @@ function gate(opts) {
7404
7620
  const command = invocation?.command;
7405
7621
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7406
7622
  const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
7623
+ if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
7407
7624
  const selection = selectFiles(root, false);
7408
7625
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7409
7626
  if (changedUi) {
@@ -7530,6 +7747,7 @@ function verdictGuard(root, command, inPlay) {
7530
7747
  const lockPath = join39(dir, LOCK);
7531
7748
  if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7532
7749
  writeLock(lockPath, dir, now);
7750
+ problems.push(...lockLeftBehind(root, surface));
7533
7751
  continue;
7534
7752
  }
7535
7753
  let lock = {};
@@ -7542,7 +7760,10 @@ function verdictGuard(root, command, inPlay) {
7542
7760
  if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
7543
7761
  const own = tweakVerdictProblems(dir, surface, lock.verdicts);
7544
7762
  problems.push(...own);
7545
- if (own.length === 0) writeLock(lockPath, dir, now);
7763
+ if (own.length === 0) {
7764
+ writeLock(lockPath, dir, now);
7765
+ problems.push(...lockLeftBehind(root, surface));
7766
+ }
7546
7767
  continue;
7547
7768
  }
7548
7769
  if (locked && locked !== now) {
@@ -7553,6 +7774,17 @@ function verdictGuard(root, command, inPlay) {
7553
7774
  }
7554
7775
  return problems;
7555
7776
  }
7777
+ function lockLeftBehind(root, surface) {
7778
+ const rel = `.jig/critique/${surface}`;
7779
+ try {
7780
+ const status = (paths) => execFileSync4("git", ["status", "--porcelain", "--", ...paths], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7781
+ if (!status([`${rel}/${LOCK}`])) return [];
7782
+ if (status(VERDICT_FILES.map((f) => `${rel}/${f}`))) return [];
7783
+ } catch {
7784
+ return [];
7785
+ }
7786
+ return [`${rel}/${LOCK} was written when you stopped, after the verdicts it records were committed. Commit it on its own (\`git add ${rel}/${LOCK} && git commit -m "chore: record ${surface}'s verdict lock"\`), and nothing else.`];
7787
+ }
7556
7788
  function tweakVerdictProblems(dir, surface, lockedVerdicts) {
7557
7789
  const record = readTweak(dir);
7558
7790
  if (!record) {
@@ -7696,6 +7928,12 @@ function seo(opts) {
7696
7928
  var packageRoot = getPackageRoot();
7697
7929
  var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
7698
7930
  function warnIfUnpublishedPin() {
7931
+ if (isDevVersion(version)) {
7932
+ console.warn(
7933
+ `Dev build ${version}: the skill, its commands and the Stop hook run the \`jig\` on your PATH, not npm, which has no such version. Keep this build installed while the project uses it, and install a published version to go back.`
7934
+ );
7935
+ return;
7936
+ }
7699
7937
  if (isPublishedBuild(packageRoot)) return;
7700
7938
  console.warn(
7701
7939
  `Note: this is a source build, so the skill pins 'npx jig-ui@${version}' \u2014 a version that may not be published. Agents reading it will not be able to run the CLI until it is. Re-run install or update from a published build to correct the pin.`
@@ -7819,7 +8057,7 @@ program.command("verdicts").description("Verify a critique's verdict files and c
7819
8057
  const p = result.previous;
7820
8058
  if (p) {
7821
8059
  const list = (ids) => ids.length ? ` (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", \u2026" : ""})` : "";
7822
- 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)}.`);
8060
+ console.log(` Since the critique before this one (${p.commit}): ${p.fixed.length} fixed${list(p.fixed)}, ${p.unchanged.length} judged ok though the lines they cited did not change${list(p.unchanged)}, ${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)}.`);
7823
8061
  }
7824
8062
  console.log(` ${result.line}`);
7825
8063
  process.exit(result.ok ? 0 : 1);
@@ -7917,7 +8155,8 @@ program.command("gate").description("Run by the Claude Code Stop hook: block sto
7917
8155
  const cwd = input.cwd ?? process.cwd();
7918
8156
  try {
7919
8157
  const projectRoot = findProjectRoot(cwd);
7920
- for (const surface of critiquedSurfaces(projectRoot)) {
8158
+ const critiqued = critiquedSurfaces(projectRoot);
8159
+ for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
7921
8160
  const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
7922
8161
  if (!page) continue;
7923
8162
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.20.1",
3
+ "version": "0.21.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",
@@ -657,7 +657,10 @@ down the spec's `regions:` and its `nav:` and find each one in that frame. A reg
657
657
  the spec names and the frame lacks is drawn now, not raised in review. The gate
658
658
  checks an HTML drawing when you stop: a frame for each of the four sizes, each
659
659
  size's regions labelled in its frame (by name where the spec names them), a
660
- frame either side of each recorded switch, and no icon or image in any frame. This matters
660
+ frame either side of each recorded switch, and no icon or image in any frame. It checks
661
+ the same while you wait for the user's word, so a drawing it refuses cannot be put to
662
+ them. When it stops you, say what it refused and fix it; never report a gate block as
663
+ waiting on the user. This matters
661
664
  most on the phone, where a region dropped for space is easy to miss and the reviewer
662
665
  is looking at what is there, not at what is not.
663
666
 
@@ -1159,11 +1162,16 @@ preserve while fixing them.
1159
1162
 
1160
1163
  **Say what happened to the last round's findings.** `verdicts` compares this
1161
1164
  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.
1165
+ this one: <n> fixed, <n> judged ok though the lines they cited did not change, <n>
1166
+ still open, <n> ruled by the owner, <n> new`, with the ids. Put that line in the
1167
+ report, as `verdicts` printed it, above the findings. The owner should not have to set two reports side
1168
+ by side to learn whether the make round worked. The comparison is made after both
1169
+ arms have written, so it tells the arms nothing.
1170
+
1171
+ A finding counts as fixed only where the lines it cited changed. One judged ok
1172
+ where nothing it pointed at changed is either two readers disagreeing about the
1173
+ same code, or a fix made somewhere the finding did not point. Look at which, and
1174
+ say so: the reading you stand by and why, or where the fix landed.
1167
1175
 
1168
1176
  List `ruled` verdicts after the findings, under their own heading, each with the
1169
1177
  decision it cites. They are not findings, and they are not hidden either.
@@ -1256,6 +1264,14 @@ colour for a mark, how versions are written), record it in `DECISIONS.md` the wa
1256
1264
  applies a rule, or answers a finding the rules already settle (a word stranded on
1257
1265
  its own line, `B-106`), there is nothing to decide; do not invent a decision.
1258
1266
 
1267
+ The owner's words are the ones tweak.json records as `change`. A `**Why:**` you
1268
+ add quotes them, in quotation marks, and nothing else; what you read into them,
1269
+ or into a screenshot the owner pointed at, goes under `**Why (inferred):**`. On
1270
+ jig-site a tweak shown a picture of a copy button wrote an exception to `E-51`
1271
+ "given directly by the owner … by reference rather than words": a ruling nobody
1272
+ gave, which every later critique would have judged the page by. The gate stops a
1273
+ tweak whose new `**Why:**` quotes words tweak.json does not hold.
1274
+
1259
1275
  ### 2. Bring the spec in line, if it disagrees
1260
1276
 
1261
1277
  If the spec says something the change contradicts, or the page will no longer
@@ -1341,6 +1357,11 @@ blocking. It also refuses when a critique's verdict files changed after `critiqu
1341
1357
  wrote them, in a session that did not run `critique`: a fix is judged by the next
1342
1358
  review, never by the builder editing the last one.
1343
1359
 
1360
+ When a `critique` or `tweak` session stops, the gate records its verdicts in
1361
+ `.jig/critique/<surface>/verdicts.lock`. That happens after you have committed, so
1362
+ if your verdicts are committed it stops you once more to commit the lock on its
1363
+ own; the next stop passes.
1364
+
1344
1365
  It judges the critiques this session touched: any with a file changed since the
1345
1366
  session began, and the current spec's surface when you ran `critique`. Another
1346
1367
  page's older critique does not hold you back. To set a critique aside for good,