jig-ui 0.20.1 → 0.22.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/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync34 } from "fs";
5
+ import { readFileSync as readFileSync36 } from "fs";
6
6
  import { homedir as homedir2 } from "os";
7
- import { join as join41 } from "path";
7
+ import { join as join43 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -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 };
@@ -5340,6 +5349,7 @@ async function init(opts) {
5340
5349
  log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
5341
5350
  for (const line of describeValidation(validation)) log(line);
5342
5351
  let surfaces = DEFAULT_SURFACES;
5352
+ let critique;
5343
5353
  if (!opts.yes) {
5344
5354
  for (let attempt = 0; attempt < 3; attempt++) {
5345
5355
  const answer = await prompt(
@@ -5367,6 +5377,12 @@ async function init(opts) {
5367
5377
  ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
5368
5378
  );
5369
5379
  }
5380
+ const critiqueAnswer = (await prompt(
5381
+ `When should pages be critiqued? Enter to decide page by page, 'each' after every build, or 'ship' to leave them for jig ship: `
5382
+ )).toLowerCase();
5383
+ if (/^(e|each)$/.test(critiqueAnswer)) critique = "each";
5384
+ else if (/^(s|ship|at-ship)$/.test(critiqueAnswer)) critique = "at-ship";
5385
+ else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
5370
5386
  }
5371
5387
  if (opts.yes) {
5372
5388
  const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
@@ -5474,7 +5490,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5474
5490
  }
5475
5491
  }
5476
5492
  if (configAction === "written") {
5477
- const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
5493
+ const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
5478
5494
  const content = `${JSON.stringify(config, null, 2)}
5479
5495
  `;
5480
5496
  writeFileSync4(configAbsPath, content, "utf8");
@@ -5490,6 +5506,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5490
5506
  const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5491
5507
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5492
5508
  const merged = {
5509
+ ...existing,
5510
+ ...existing.critique === void 0 && critique ? { critique } : {},
5493
5511
  brand: brandStillResolves ? existingBrand : brandRelPath,
5494
5512
  // Same contract as the 'written' branch: never invent a mapping under
5495
5513
  // `--yes`. An existing declaration is always kept.
@@ -5771,7 +5789,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
5771
5789
  import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5772
5790
 
5773
5791
  // src/probe/script.ts
5774
- var PROBE_VERSION = 8;
5792
+ var PROBE_VERSION = 9;
5775
5793
  var PROBE_SCRIPT = `(async () => {
5776
5794
  const doc = document.documentElement;
5777
5795
  // checkVisibility also sees content a closed <details> hides: Chromium hides
@@ -5786,9 +5804,12 @@ var PROBE_SCRIPT = `(async () => {
5786
5804
  const text = document.body.innerText || '';
5787
5805
  // I-118: a verbatim quotation keeps its own punctuation. Text marked as a
5788
5806
  // 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.
5807
+ // the page's own copy around it still counts. So is a tool's output or a
5808
+ // command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
5809
+ // quotes \`jig check\`'s real output, whose own wording has an em dash, and
5810
+ // the probe blocked a page for words it had rightly not rewritten.
5790
5811
  let ownText = text;
5791
- for (const q of document.querySelectorAll('blockquote, q')) {
5812
+ for (const q of document.querySelectorAll('blockquote, q, pre, code, samp, kbd')) {
5792
5813
  const quoted = (q.innerText || '').trim();
5793
5814
  if (quoted) ownText = ownText.split(quoted).join('\\n');
5794
5815
  }
@@ -6557,12 +6578,22 @@ function decisionSections(body) {
6557
6578
  close();
6558
6579
  return sections;
6559
6580
  }
6560
- function unsourcedReasons(current, before) {
6581
+ function changedWhys(current, before, newOnly = false) {
6561
6582
  const then = decisionSections(before);
6562
- const problems = [];
6583
+ const paragraphs = (text) => text.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => /^\*\*Why\b[^*]*:\*\*/.test(p));
6584
+ const out = [];
6563
6585
  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));
6586
+ const old = then.get(name);
6587
+ if (old === text) continue;
6588
+ const kept = newOnly && old !== void 0 ? new Set(paragraphs(old)) : /* @__PURE__ */ new Set();
6589
+ const whys = paragraphs(text).filter((p) => !kept.has(p));
6590
+ if (whys.length) out.push([name, whys]);
6591
+ }
6592
+ return out;
6593
+ }
6594
+ function unsourcedReasons(current, before, opts = {}) {
6595
+ const problems = [];
6596
+ for (const [name, whys] of changedWhys(current, before, opts.newWhysOnly)) {
6566
6597
  for (const why of whys) {
6567
6598
  const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6568
6599
  const said = why.replace(/^\s*\*\*Why\b[^*]*:\*\*/, "").trim();
@@ -6575,6 +6606,26 @@ function unsourcedReasons(current, before) {
6575
6606
  }
6576
6607
  return problems;
6577
6608
  }
6609
+ function quotesNotFrom(current, before, words) {
6610
+ const problems = [];
6611
+ for (const [name, whys] of changedWhys(current, before, true)) {
6612
+ for (const why of whys) {
6613
+ const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6614
+ if (/inferred/i.test(label)) continue;
6615
+ const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
6616
+ if (missing.length) {
6617
+ 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):**\`.`);
6618
+ break;
6619
+ }
6620
+ }
6621
+ }
6622
+ return problems;
6623
+ }
6624
+ var normQuote = (t) => t.toLowerCase().replace(/\\(?=["'\\])/g, "").replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, " ").trim();
6625
+ function quoteHeld(quote, words) {
6626
+ const said = normQuote(words);
6627
+ return quote.split(/…|\.\.\./).map((part) => normQuote(part).replace(/^[\s.,;:]+|[\s.,;:!?]+$/g, "")).filter(Boolean).every((part) => said.includes(part));
6628
+ }
6578
6629
 
6579
6630
  // src/check/spec-shape.ts
6580
6631
  import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
@@ -6605,6 +6656,10 @@ function specProblems(spec) {
6605
6656
  if (specIndexableField(front) === "unreadable") {
6606
6657
  problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
6607
6658
  }
6659
+ const critique = /^\s*critique\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
6660
+ if (critique !== void 0 && !/^(each|at-ship)$/i.test(critique)) {
6661
+ problems.push(`${spec.path}: \`critique: ${critique}\` is neither \`each\` nor \`at-ship\`. Leave it out to follow the project's default.`);
6662
+ }
6608
6663
  for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6609
6664
  if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6610
6665
  }
@@ -6615,7 +6670,7 @@ function specProblems(spec) {
6615
6670
  problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
6616
6671
  continue;
6617
6672
  }
6618
- if (line[1].trim()) {
6673
+ if (line[1].replace(/(^|\s)#.*$/, "").trim()) {
6619
6674
  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
6675
  continue;
6621
6676
  }
@@ -6918,10 +6973,12 @@ function previousFindings(projectRoot, dir) {
6918
6973
  const files = ["screen.json", "code.json", "decisions.json"];
6919
6974
  const paths = files.map((f) => `${rel}/${f}`);
6920
6975
  let commit;
6976
+ let target;
6921
6977
  try {
6922
6978
  const dirty = git(["status", "--porcelain", "--", ...paths]).trim() !== "";
6923
6979
  const touched = git(["log", "--format=%H", "-2", "--", ...paths]).split("\n").filter(Boolean);
6924
6980
  commit = dirty ? touched[0] ?? "" : touched[1] ?? "";
6981
+ target = dirty ? void 0 : touched[0];
6925
6982
  } catch {
6926
6983
  return void 0;
6927
6984
  }
@@ -6942,7 +6999,7 @@ function previousFindings(projectRoot, dir) {
6942
6999
  }
6943
7000
  for (const v of Array.isArray(body.verdicts) ? body.verdicts : []) {
6944
7001
  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);
7002
+ if (key && typeof v.verdict === "string") out.set(f === "decisions.json" ? `"${key}"` : key.toUpperCase(), { verdict: v.verdict, reason: String(v.reason ?? "") });
6946
7003
  }
6947
7004
  return out;
6948
7005
  };
@@ -6952,22 +7009,69 @@ function previousFindings(projectRoot, dir) {
6952
7009
  for (const [k, v] of read(f, commit)) before.set(k, v);
6953
7010
  for (const [k, v] of read(f)) now.set(k, v);
6954
7011
  }
6955
- const result = { commit: commit.slice(0, 7), fixed: [], open: [], added: [], ruled: [] };
7012
+ const changed = sourceChanges(git, commit, target);
7013
+ const result = { commit: commit.slice(0, 7), fixed: [], unchanged: [], open: [], added: [], ruled: [] };
6956
7014
  for (const [k, v] of before) {
6957
- if (v !== "finding") continue;
6958
- const after = now.get(k);
7015
+ if (v.verdict !== "finding") continue;
7016
+ const after = now.get(k)?.verdict;
6959
7017
  if (after === "finding") result.open.push(k);
6960
7018
  else if (after === "ruled") result.ruled.push(k);
6961
- else if (after === "ok" || after === "n/a") result.fixed.push(k);
7019
+ else if (after === "ok" || after === "n/a") (changed(v.reason) ? result.fixed : result.unchanged).push(k);
6962
7020
  }
6963
- for (const [k, v] of now) if (v === "finding" && before.get(k) !== "finding") result.added.push(k);
7021
+ for (const [k, v] of now) if (v.verdict === "finding" && before.get(k)?.verdict !== "finding") result.added.push(k);
6964
7022
  return result;
6965
7023
  }
7024
+ var CITATION = /([\w@.\-[\]/]*[\w\]-]\.[a-z][a-z0-9]{0,5})(?::(\d+(?:-\d+)?(?:,\s*\d+(?:-\d+)?)*))?/gi;
7025
+ function sourceChanges(git, from, to) {
7026
+ const range = to ? [from, to] : [from];
7027
+ let tracked = [];
7028
+ let changedFiles = /* @__PURE__ */ new Set();
7029
+ try {
7030
+ tracked = git(["ls-tree", "-r", "--name-only", from]).split("\n").filter((f) => f && !f.startsWith(".jig/"));
7031
+ changedFiles = new Set(git(["diff", "--name-only", ...range, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean));
7032
+ } catch {
7033
+ }
7034
+ const hunks = /* @__PURE__ */ new Map();
7035
+ const oldRanges = (file) => {
7036
+ let out = hunks.get(file);
7037
+ if (out) return out;
7038
+ out = [];
7039
+ try {
7040
+ for (const m of git(["diff", "-U0", ...range, "--", file]).matchAll(/^@@ -(\d+)(?:,(\d+))? /gm)) {
7041
+ const start = Number(m[1]);
7042
+ const count = m[2] === void 0 ? 1 : Number(m[2]);
7043
+ out.push(count === 0 ? [start, start + 1] : [start, start + count - 1]);
7044
+ }
7045
+ } catch {
7046
+ }
7047
+ hunks.set(file, out);
7048
+ return out;
7049
+ };
7050
+ return (reason) => {
7051
+ let cited = false;
7052
+ for (const m of reason.matchAll(CITATION)) {
7053
+ const path = m[1].replace(/^\.\//, "");
7054
+ const matches2 = tracked.filter((f) => f === path || f.endsWith(`/${path}`));
7055
+ if (!matches2.length) continue;
7056
+ cited = true;
7057
+ const lines = (m[2] ?? "").split(",").map((r) => r.trim()).filter(Boolean).map((r) => {
7058
+ const [a, b] = r.split("-").map(Number);
7059
+ return [a, b ?? a];
7060
+ });
7061
+ for (const file of matches2) {
7062
+ if (!changedFiles.has(file)) continue;
7063
+ if (!lines.length) return true;
7064
+ if (oldRanges(file).some(([hs, he]) => lines.some(([s, e]) => hs <= e && he >= s))) return true;
7065
+ }
7066
+ }
7067
+ return cited ? false : changedFiles.size > 0;
7068
+ };
7069
+ }
6966
7070
 
6967
7071
  // src/commands/gate.ts
6968
- import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
6969
- import { createHash as createHash2 } from "crypto";
6970
- import { join as join39 } from "path";
7072
+ import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7073
+ import { createHash as createHash3 } from "crypto";
7074
+ import { join as join42 } from "path";
6971
7075
 
6972
7076
  // src/check/mockup-drawing.ts
6973
7077
  import { readFileSync as readFileSync31 } from "fs";
@@ -7053,7 +7157,7 @@ function specSwitches(specBody) {
7053
7157
  return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^(--)?breakpoint-/, "")).filter(Boolean);
7054
7158
  }
7055
7159
  function readFrames(html) {
7056
- const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bframe\b[^"']*["'][^>]*>/gi)];
7160
+ const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["']([^"']*)["'][^>]*>/gi)].filter((m) => m[1].split(/\s+/).includes("frame"));
7057
7161
  return opens.map((open, i) => {
7058
7162
  const tag = open[0];
7059
7163
  const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
@@ -7119,7 +7223,9 @@ function regionDrawn(labels, region) {
7119
7223
  const found = (name) => labels.some((label) => labelMatches(label, name) || labelMatches(stripQualifiers(label), name));
7120
7224
  return found(region.name) || !!region.parts && region.parts.every(found);
7121
7225
  }
7122
- function labelMatches(label, name) {
7226
+ function labelMatches(rawLabel, rawName) {
7227
+ const label = rawLabel.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
7228
+ const name = rawName.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
7123
7229
  return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
7124
7230
  }
7125
7231
  function normalise(text) {
@@ -7139,12 +7245,316 @@ function sizeBlock2(sizes, size) {
7139
7245
 
7140
7246
  // src/commands/gate.ts
7141
7247
  import { execFileSync as execFileSync4 } from "child_process";
7248
+
7249
+ // src/commands/ship.ts
7250
+ import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
7251
+ import { join as join40 } from "path";
7252
+
7253
+ // src/commands/seo.ts
7254
+ import { readFileSync as readFileSync32 } from "fs";
7255
+ import { join as join39 } from "path";
7256
+ var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7257
+ var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7258
+ var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
7259
+ var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
7260
+ var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
7261
+ var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
7262
+ var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
7263
+ var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
7264
+ function isPage(file, raw) {
7265
+ if (TEST_FILE.test(file)) return false;
7266
+ if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
7267
+ const route = ROUTE_DIR.exec(file);
7268
+ if (route) {
7269
+ const rest = route[1];
7270
+ const name = rest.split("/").pop();
7271
+ if (name.startsWith("+")) return name === "+page.svelte";
7272
+ if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
7273
+ if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
7274
+ if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
7275
+ return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
7276
+ }
7277
+ return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
7278
+ }
7279
+ function routeOf(file) {
7280
+ const path = file.replace(/^(?:src\/pages|app\/pages|app\/routes|src\/routes|pages|routes|app|src|public|content)\//, "").replace(/(^|\/)(index|\+?page)\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "$1").replace(/\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "").replace(/\/$/, "");
7281
+ return `/${path}`.replace(/\/+/g, "/");
7282
+ }
7283
+ function seo(opts) {
7284
+ const root = opts.projectRoot;
7285
+ const files = selectFiles(root, true).files;
7286
+ const findings = [];
7287
+ const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
7288
+ const pages = [];
7289
+ let sitemapFiles = [];
7290
+ let robotsFiles = [];
7291
+ const sitemapRoutes = /* @__PURE__ */ new Map();
7292
+ const pathLocs = [];
7293
+ for (const file of files) {
7294
+ let raw;
7295
+ try {
7296
+ raw = readFileSync32(join39(root, file), "utf8");
7297
+ } catch {
7298
+ continue;
7299
+ }
7300
+ if (SITEMAP_FILE.test(file)) {
7301
+ sitemapFiles.push(file);
7302
+ for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
7303
+ const value = (match[1] ?? match[2] ?? "").trim();
7304
+ if (!value) continue;
7305
+ if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
7306
+ const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
7307
+ sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
7308
+ }
7309
+ continue;
7310
+ }
7311
+ if (ROBOTS_FILE.test(file)) {
7312
+ robotsFiles.push(file);
7313
+ continue;
7314
+ }
7315
+ if (!isPage(file, raw)) continue;
7316
+ const found = readMetadata(raw);
7317
+ const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
7318
+ pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
7319
+ }
7320
+ for (const page of pages.filter((p) => p.noindex)) {
7321
+ const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
7322
+ if (listed) {
7323
+ add(listed, "J-124", "error", `the sitemap lists ${page.route}, and ${page.file} says noindex. One answer per route: a crawler reading two picks one.`);
7324
+ }
7325
+ }
7326
+ if (pathLocs.length > 0) {
7327
+ const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
7328
+ add(pathLocs[0].file, "J-124", "error", `${pathLocs.length} sitemap entr${pathLocs.length === 1 ? "y is a path" : "ies are paths"} (${shown}), not a full URL. A crawler drops them; with no origin yet, no sitemap is the honest one.`);
7329
+ }
7330
+ const byTitle = /* @__PURE__ */ new Map();
7331
+ for (const page of pages) {
7332
+ if (!page.title || page.noindex) continue;
7333
+ byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
7334
+ }
7335
+ for (const [title, where] of byTitle) {
7336
+ if (where.length > 1) {
7337
+ add(where[1], "J-121", "warning", `"${title}" is the title of ${where.length} pages (${where.join(", ")}). A result page showing the same line twice tells a reader nothing about either.`);
7338
+ }
7339
+ }
7340
+ const indexable = pages.filter((p) => !p.noindex);
7341
+ const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
7342
+ if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
7343
+ add(sitemapFiles[0], "J-127", "error", "the sitemap lists no routes. An empty sitemap is not a missing one: it is a claim that the site has nothing.");
7344
+ }
7345
+ const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
7346
+ const line = `JIG_SEO: pages=${pages.length} indexable=${indexable.length} noindex=${pages.length - indexable.length} sitemap=${sitemapFiles.length > 0 ? sitemapRoutes.size : "none"} robots=${robotsFiles.length > 0 ? "yes" : "no"} overlong=${titleBudget} findings=${findings.length}`;
7347
+ return { findings, pages: pages.length, indexable: indexable.length, line };
7348
+ }
7349
+
7350
+ // src/commands/ship.ts
7351
+ var NOT_COVERED = "security, performance, what a screen reader or a real device does, and deployment itself";
7352
+ function specCritique(specBody) {
7353
+ const front = specBody?.split(/^---\s*$/m)[1] ?? "";
7354
+ const value = /^\s*critique\s*:\s*([\w-]+)/im.exec(front)?.[1]?.toLowerCase();
7355
+ return value === "each" || value === "at-ship" ? value : void 0;
7356
+ }
7357
+ function critiqueAtShip(projectRoot, specBody) {
7358
+ const own = specCritique(specBody);
7359
+ if (own) return own === "at-ship";
7360
+ try {
7361
+ return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7362
+ } catch {
7363
+ return false;
7364
+ }
7365
+ }
7366
+ function tweakDeferred(dir) {
7367
+ let t;
7368
+ try {
7369
+ t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
7370
+ } catch {
7371
+ return false;
7372
+ }
7373
+ if (t.deferred === void 0 || t.deferred === false) return false;
7374
+ let judged;
7375
+ try {
7376
+ judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
7377
+ } catch {
7378
+ }
7379
+ return judged !== t.at;
7380
+ }
7381
+ function pageChecksum(html) {
7382
+ return checksum(html.replace(/(href|src)="[^"]*\.(css|js|mjs)(\?[^"]*)?"/g, '$1="asset.$2"'));
7383
+ }
7384
+ function changedSinceLock(projectRoot, dir) {
7385
+ let lock;
7386
+ try {
7387
+ lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
7388
+ } catch {
7389
+ return void 0;
7390
+ }
7391
+ const file = lock.page?.file;
7392
+ if (!file || !lock.page?.checksum) return void 0;
7393
+ try {
7394
+ const html = readFileSync33(join40(projectRoot, file), "utf8");
7395
+ return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
7396
+ } catch {
7397
+ return void 0;
7398
+ }
7399
+ }
7400
+ function pageStatus(projectRoot, surface) {
7401
+ const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
7402
+ const front = spec.split(/^---\s*$/m)[1] ?? "";
7403
+ if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
7404
+ return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
7405
+ }
7406
+ const dir = join40(projectRoot, ".jig", "critique", surface);
7407
+ if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
7408
+ return { surface, state: "never", detail: "never critiqued" };
7409
+ }
7410
+ if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
7411
+ const changed = changedSinceLock(projectRoot, dir);
7412
+ if (changed) return { surface, state: "changed", detail: `${changed} changed after it was judged` };
7413
+ const v = verifyVerdicts({ projectRoot, surface });
7414
+ const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
7415
+ let judgedPage = false;
7416
+ try {
7417
+ judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
7418
+ } catch {
7419
+ }
7420
+ if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
7421
+ if (stale) return { surface, state: "reprobe", detail: `its page is as judged, but the styles or scripts it loads changed: re-probe it (\`jig verdicts ${surface} --reprobe\`)` };
7422
+ if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
7423
+ const findings = v.screen.findings + v.code.findings + v.decisions.findings;
7424
+ if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
7425
+ return { surface, state: "judged", detail: "judged as it stands, nothing open" };
7426
+ }
7427
+ function ship(opts) {
7428
+ const root = opts.projectRoot;
7429
+ const mechanical = check({ projectRoot: root, homeDir: "", version: opts.version, all: true, ci: true });
7430
+ const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
7431
+ const seoResult = seo({ projectRoot: root });
7432
+ const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
7433
+ const specsDir = join40(root, ".jig", "specs");
7434
+ const surfaces = existsSync24(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
7435
+ const pages = surfaces.map((s) => pageStatus(root, s));
7436
+ const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress");
7437
+ const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0;
7438
+ const count = (state) => pages.filter((p) => p.state === state).length;
7439
+ const line = `JIG_SHIP: ready=${ready ? "yes" : "no"} mechanical=${mechanicalErrors} seo=${seoErrors} pages=${pages.length} judged=${count("judged")} owed=${owed.length} in-progress=${count("in-progress")}`;
7440
+ const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7" };
7441
+ const report2 = [
7442
+ ` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
7443
+ ` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
7444
+ ...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
7445
+ owed.length ? ` Critique each page marked \u2717 (\`/jig critique <page>\`); fix what it finds, or record the owner's ruling, and run \`jig ship\` again.` : "",
7446
+ ` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
7447
+ ` ${line}`
7448
+ ].filter(Boolean).join("\n");
7449
+ return { ready, mechanicalErrors, seoErrors, pages, line, report: report2 };
7450
+ }
7451
+
7452
+ // src/check/spec-checked.ts
7453
+ import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
7454
+ import { createHash as createHash2 } from "crypto";
7455
+ import { join as join41 } from "path";
7456
+ function specChecksum(body) {
7457
+ return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7458
+ }
7459
+ var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7460
+ function mockupWordProblems(spec, then, owner) {
7461
+ const now = mockupLine(spec.body);
7462
+ if (now === mockupLine(then)) return [];
7463
+ const m = /^(approved|skipped)\b(.*)$/i.exec(now);
7464
+ if (!m) return [];
7465
+ const word = m[1].toLowerCase();
7466
+ const quoted = /["“]([^"”]+)["”]/.exec(m[2])?.[1]?.trim();
7467
+ if (!quoted) {
7468
+ return [`${spec.path}: \`mockup: ${word}\` is recorded without the owner's words. Write \`mockup: ${word} \u2014 "<what they said>"\`, quoting the reply that ${word === "approved" ? "approved it" : "said to skip it"}.`];
7469
+ }
7470
+ if (!quoteHeld(quoted, owner)) {
7471
+ return [`${spec.path}: \`mockup: ${word}\` quotes "${quoted}", which the owner did not say in this session. Quote their reply as they wrote it; ${word === "approved" ? "an approval" : "a skip"} nobody gave is not one.`];
7472
+ }
7473
+ return [];
7474
+ }
7475
+ function mockupPending(body) {
7476
+ const now = mockupLine(body);
7477
+ return !now || /^pending\b/i.test(now);
7478
+ }
7479
+ function ownerQuotes(body) {
7480
+ const quote = String.raw`\\?["“]((?:[^"“”\\\n]|\\(?!"))+?)\\?["”]`;
7481
+ const patterns = [
7482
+ new RegExp(String.raw`\bowner(?:['’]s)?\b[^"“”\n]{0,30}?` + quote, "gi"),
7483
+ new RegExp(String.raw`\buser(?:['’]s words)?\s*(?:(?:said|says|ruled|wrote|asked|answered|replied)\s*[:,]?|:)\s*` + quote, "gi")
7484
+ ];
7485
+ const out = [];
7486
+ for (const pattern of patterns) {
7487
+ for (const m of body.matchAll(pattern)) {
7488
+ const q = m[1].trim();
7489
+ if (q.length >= 3 && !/^not given\b/i.test(q) && !out.includes(q)) out.push(q);
7490
+ }
7491
+ }
7492
+ return out;
7493
+ }
7494
+ function specCheckProblems(root, spec, then, owner, decisions) {
7495
+ if (spec.body === then) return [];
7496
+ const problems = [];
7497
+ const held = new Set(ownerQuotes(then));
7498
+ const sources = [owner, then, decisions].join("\n");
7499
+ const unheld = ownerQuotes(spec.body).filter((q) => !held.has(q) && !quoteHeld(q, sources));
7500
+ if (unheld.length) {
7501
+ problems.push(
7502
+ `${spec.path} gives ${unheld.length === 1 ? "a quotation" : `${unheld.length} quotations`} as the owner's that the owner's words do not hold: ${unheld.slice(0, 3).map((q) => `"${q.length > 80 ? `${q.slice(0, 80)}\u2026` : q}"`).join(", ")}. Quote the owner as they said it, or say it in your own words without quotation marks and ask them.`
7503
+ );
7504
+ }
7505
+ const recordPath = `.jig/specs/${spec.slug}.checked.json`;
7506
+ let record;
7507
+ try {
7508
+ record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
7509
+ } catch {
7510
+ problems.push(
7511
+ `${recordPath} is missing. Before the owner is asked to confirm, a reader who did not write the spec checks its quotations against the owner's words and its facts against their sources, and writes that record (step 3c). The owner checks the spec by the sheet it gives them.`
7512
+ );
7513
+ return problems;
7514
+ }
7515
+ if (record.spec !== specChecksum(spec.body)) {
7516
+ problems.push(`${recordPath} is of an earlier spec: the spec changed after it was checked. Have the spec as it stands checked again; the owner confirms what was checked.`);
7517
+ }
7518
+ if (!Array.isArray(record.quotes) || !Array.isArray(record.facts)) {
7519
+ problems.push(`${recordPath} needs \`quotes\` and \`facts\`, each a list: every quotation given as the owner's, and every statement of how something works with the source it was checked against.`);
7520
+ return problems;
7521
+ }
7522
+ const facts = record.facts;
7523
+ const incomplete = facts.filter((f) => typeof f?.claim !== "string" || typeof f.source !== "string" || !f.source.trim() || ![true, false, "unchecked"].includes(f.holds));
7524
+ if (incomplete.length) {
7525
+ problems.push(`${recordPath}: ${incomplete.length} of its facts lack a \`claim\`, a \`source\` or a \`holds\` of true, false or "unchecked".`);
7526
+ }
7527
+ const wrong = facts.filter((f) => f?.holds === false);
7528
+ if (wrong.length) {
7529
+ problems.push(
7530
+ `${spec.path} states ${wrong.length === 1 ? "a fact" : `${wrong.length} facts`} its reader found false at the source: ${wrong.slice(0, 3).map((f) => `"${String(f.claim)}" (${String(f.source)})`).join("; ")}. Fix the spec, then have it checked again, before the owner is asked.`
7531
+ );
7532
+ }
7533
+ const unsaid = facts.filter((f) => f?.holds === "unchecked" && !(typeof f.note === "string" && f.note.trim()));
7534
+ if (unsaid.length) problems.push(`${recordPath}: ${unsaid.length} unchecked ${unsaid.length === 1 ? "fact says" : "facts say"} nothing of why. Say in \`note\` why the source could not be opened; the owner opens it instead.`);
7535
+ const missing = facts.map((f) => typeof f?.source === "string" ? f.source.trim() : "").filter((s) => s && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)).filter((s) => {
7536
+ const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
7537
+ if (!file || !existsSync25(join41(root, file))) return true;
7538
+ if (!line) return false;
7539
+ try {
7540
+ return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
7541
+ } catch {
7542
+ return true;
7543
+ }
7544
+ });
7545
+ if (missing.length) {
7546
+ problems.push(`${recordPath} cites ${missing.length === 1 ? "a source" : "sources"} the project does not have: ${missing.slice(0, 4).join(", ")}. A fact is checked at a source that exists; name it as a path from the project root, or a URL.`);
7547
+ }
7548
+ return problems;
7549
+ }
7550
+
7551
+ // src/commands/gate.ts
7142
7552
  var MAX_BLOCKS = 3;
7143
7553
  function lastJigInvocation(transcriptPath) {
7144
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7554
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7145
7555
  let text;
7146
7556
  try {
7147
- text = readFileSync32(transcriptPath, "utf8");
7557
+ text = readFileSync35(transcriptPath, "utf8");
7148
7558
  } catch {
7149
7559
  return void 0;
7150
7560
  }
@@ -7161,18 +7571,42 @@ function lastJigInvocation(transcriptPath) {
7161
7571
  const content = entry.message?.content;
7162
7572
  const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
7163
7573
  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];
7574
+ const args = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1] ?? /^\s*\/jig\s+([^\n]*)/.exec(t)?.[1];
7165
7575
  const [command, surface] = (args ?? "").trim().split(/\s+/);
7166
7576
  if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
7167
7577
  }
7168
7578
  }
7169
7579
  return found;
7170
7580
  }
7581
+ function ownerWords(transcriptPath) {
7582
+ if (!transcriptPath || !existsSync26(transcriptPath)) return "";
7583
+ const said = [];
7584
+ try {
7585
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7586
+ if (!line.includes('"user"')) continue;
7587
+ let entry;
7588
+ try {
7589
+ entry = JSON.parse(line);
7590
+ } catch {
7591
+ continue;
7592
+ }
7593
+ if (entry.type !== "user" || entry.isMeta) continue;
7594
+ const content = entry.message?.content;
7595
+ if (typeof content === "string") said.push(content);
7596
+ else if (Array.isArray(content)) {
7597
+ for (const b of content) if (b?.type === "text" && typeof b.text === "string") said.push(b.text);
7598
+ }
7599
+ }
7600
+ } catch {
7601
+ return "";
7602
+ }
7603
+ return said.join("\n");
7604
+ }
7171
7605
  function lastAssistantText(transcriptPath) {
7172
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7606
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7173
7607
  let found;
7174
7608
  try {
7175
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7609
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7176
7610
  if (!line.includes('"assistant"')) continue;
7177
7611
  let entry;
7178
7612
  try {
@@ -7196,9 +7630,9 @@ function asksOwner(text) {
7196
7630
  }
7197
7631
  var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
7198
7632
  function sessionStart(transcriptPath) {
7199
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7633
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7200
7634
  try {
7201
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7635
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7202
7636
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
7203
7637
  if (m) {
7204
7638
  const t = Date.parse(m[1]);
@@ -7213,20 +7647,24 @@ function verdictsMtime(dir) {
7213
7647
  let newest = 0;
7214
7648
  for (const name of VERDICT_FILES) {
7215
7649
  try {
7216
- newest = Math.max(newest, statSync5(join39(dir, name)).mtimeMs);
7650
+ newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
7217
7651
  } catch {
7218
7652
  }
7219
7653
  }
7220
7654
  return newest;
7221
7655
  }
7222
7656
  function surfacesInPlay(root, command, transcriptPath, surface) {
7223
- const critiqueDir = join39(root, ".jig", "critique");
7224
- if (!existsSync24(critiqueDir)) return [];
7225
- const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7657
+ const critiqueDir = join42(root, ".jig", "critique");
7658
+ if (!existsSync26(critiqueDir)) return [];
7659
+ const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7226
7660
  const start = sessionStart(transcriptPath);
7227
7661
  if (start === void 0) return all;
7228
7662
  const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
7229
- return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
7663
+ return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
7664
+ }
7665
+ function surfacesToProbe(root, input) {
7666
+ const invocation = lastJigInvocation(input.transcript_path);
7667
+ return surfacesInPlay(root, invocation?.command, input.transcript_path, invocation?.surface);
7230
7668
  }
7231
7669
  function fileAtSessionStart(root, path, start) {
7232
7670
  const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -7237,19 +7675,48 @@ function fileAtSessionStart(root, path, start) {
7237
7675
  return "";
7238
7676
  }
7239
7677
  }
7240
- function commandProblems(root, command, surface, start) {
7678
+ function changedOutsideRecords(root, start) {
7679
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7680
+ try {
7681
+ const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
7682
+ if (!base) return [];
7683
+ const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7684
+ const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7685
+ try {
7686
+ return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
7687
+ } catch {
7688
+ return false;
7689
+ }
7690
+ });
7691
+ return [.../* @__PURE__ */ new Set([...changed, ...created])].sort();
7692
+ } catch {
7693
+ return [];
7694
+ }
7695
+ }
7696
+ function drawingChangedAfterApproval(root, specPath, at) {
7697
+ const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7698
+ try {
7699
+ const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7700
+ if (!approvedIn) return void 0;
7701
+ const then = git(["show", `${approvedIn}:./${at}`]);
7702
+ return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7703
+ } catch {
7704
+ return void 0;
7705
+ }
7706
+ }
7707
+ function commandProblems(root, command, surface, start, owner = "") {
7241
7708
  const problems = [];
7242
7709
  const spec = specFor(root, surface);
7243
7710
  if (command === "decide") {
7244
7711
  const found = decisionsFile(root);
7245
7712
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
7246
7713
  else {
7247
- const body = readFileSync32(join39(root, found), "utf8");
7714
+ const body = readFileSync35(join42(root, found), "utf8");
7248
7715
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
7249
7716
  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
7717
  }
7251
7718
  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}`));
7719
+ problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start), { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
7253
7720
  }
7254
7721
  }
7255
7722
  if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
@@ -7259,6 +7726,10 @@ function commandProblems(root, command, surface, start) {
7259
7726
  if (problems.length === 0) problems.push(...navProblems(spec));
7260
7727
  }
7261
7728
  }
7729
+ if (command === "spec" && spec) problems.push(...specChecked(root, spec, start, owner));
7730
+ if ((command === "spec" || command === "mockup" || command === "make") && spec) {
7731
+ problems.push(...mockupWordProblems(spec, fileAtSessionStart(root, spec.path, start), owner));
7732
+ }
7262
7733
  if (command === "mockup" && spec) {
7263
7734
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7264
7735
  const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
@@ -7266,12 +7737,12 @@ function commandProblems(root, command, surface, start) {
7266
7737
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7267
7738
  if (/^approved/i.test(mockup)) {
7268
7739
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
7269
- else if (!/^https?:/i.test(at) && !existsSync24(join39(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7740
+ else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7270
7741
  else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
7271
7742
  }
7272
- if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7743
+ if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7273
7744
  }
7274
- if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
7745
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
7275
7746
  if ((command === "spec" || command === "make") && spec) {
7276
7747
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7277
7748
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
@@ -7285,12 +7756,31 @@ function commandProblems(root, command, surface, start) {
7285
7756
  }
7286
7757
  }
7287
7758
  }
7759
+ if (command === "spec" || command === "mockup" || command === "critique") {
7760
+ const outside = changedOutsideRecords(root, start);
7761
+ if (outside.length) {
7762
+ problems.push(
7763
+ `${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.")
7764
+ );
7765
+ }
7766
+ }
7767
+ if (command === "mockup" && spec) {
7768
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7769
+ const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7770
+ const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7771
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7772
+ const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7773
+ if (changedAfter) {
7774
+ 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.`);
7775
+ }
7776
+ }
7777
+ }
7288
7778
  if (command === "make") {
7289
7779
  const found = decisionsFile(root);
7290
7780
  if (found) {
7291
7781
  let now = "";
7292
7782
  try {
7293
- now = readFileSync32(join39(root, found), "utf8");
7783
+ now = readFileSync35(join42(root, found), "utf8");
7294
7784
  } catch {
7295
7785
  }
7296
7786
  const then = fileAtSessionStart(root, found, start);
@@ -7299,9 +7789,39 @@ function commandProblems(root, command, surface, start) {
7299
7789
  }
7300
7790
  }
7301
7791
  }
7792
+ if (command === "tweak" && spec) {
7793
+ const found = decisionsFile(root);
7794
+ if (found) {
7795
+ let now = "";
7796
+ try {
7797
+ now = readFileSync35(join42(root, found), "utf8");
7798
+ } catch {
7799
+ }
7800
+ const then = fileAtSessionStart(root, found, start);
7801
+ if (now && now !== then) {
7802
+ problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7803
+ const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
7804
+ if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7805
+ }
7806
+ }
7807
+ }
7808
+ if (command !== "update" && command !== "init" && command !== "install") {
7809
+ for (const file of modeFiles(root)) {
7810
+ let now = "";
7811
+ try {
7812
+ now = readFileSync35(join42(root, file), "utf8");
7813
+ } catch {
7814
+ continue;
7815
+ }
7816
+ const then = fileAtSessionStart(root, file, start);
7817
+ if (then && now !== then) {
7818
+ 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).`);
7819
+ }
7820
+ }
7821
+ }
7302
7822
  if (command === "critique") {
7303
- const dir = join39(root, ".jig", "critique");
7304
- const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
7823
+ const dir = join42(root, ".jig", "critique");
7824
+ const surfaces = existsSync26(dir) ? readdirSync16(dir).filter((s) => existsSync26(join42(dir, s, "screen.json")) || existsSync26(join42(dir, s, "code.json"))) : [];
7305
7825
  for (const surface2 of surfaces) {
7306
7826
  const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7307
7827
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -7311,13 +7831,66 @@ function commandProblems(root, command, surface, start) {
7311
7831
  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
7832
  }
7313
7833
  }
7834
+ const own = specFor(root, surface)?.slug;
7835
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
7314
7836
  if (surfaces.length === 0) {
7315
7837
  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
7838
  }
7317
7839
  }
7318
7840
  return problems;
7319
7841
  }
7320
- function tweakProblems(root, spec) {
7842
+ function drawingBeforeAsking(root, surface) {
7843
+ const spec = specFor(root, surface);
7844
+ if (!spec) return [];
7845
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7846
+ const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7847
+ const at = named || `.jig/mockups/${spec.slug}.html`;
7848
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
7849
+ const found = mockupDrawingProblems(root, spec.body, at);
7850
+ return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
7851
+ }
7852
+ function specChecked(root, spec, start, owner) {
7853
+ const found = decisionsFile(root);
7854
+ let decisions = "";
7855
+ try {
7856
+ if (found) decisions = readFileSync35(join42(root, found), "utf8");
7857
+ } catch {
7858
+ }
7859
+ return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
7860
+ }
7861
+ function modeFiles(root) {
7862
+ try {
7863
+ const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
7864
+ return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
7865
+ } catch {
7866
+ return [];
7867
+ }
7868
+ }
7869
+ function reportOmissions(dir, surface) {
7870
+ let report2;
7871
+ try {
7872
+ report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
7873
+ } catch {
7874
+ return [];
7875
+ }
7876
+ const missing = [];
7877
+ for (const f of VERDICT_FILES) {
7878
+ let file;
7879
+ try {
7880
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7881
+ } catch {
7882
+ continue;
7883
+ }
7884
+ for (const v of Array.isArray(file.verdicts) ? file.verdicts : []) {
7885
+ if (v.verdict !== "finding") continue;
7886
+ const key = String(v.id ?? v.decision ?? "").trim();
7887
+ if (key && !report2.includes(key.toLowerCase())) missing.push(v.id ? key : `"${key}"`);
7888
+ }
7889
+ }
7890
+ if (!missing.length) return [];
7891
+ 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).`];
7892
+ }
7893
+ function tweakProblems(root, spec, owner = "") {
7321
7894
  const problems = [];
7322
7895
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7323
7896
  if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
@@ -7325,13 +7898,21 @@ function tweakProblems(root, spec) {
7325
7898
  if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
7326
7899
  else problems.push(...structureSinceApproval(root, spec, front));
7327
7900
  const surface = spec.slug;
7328
- const record = readTweak(join39(root, ".jig", "critique", surface));
7901
+ const record = readTweak(join42(root, ".jig", "critique", surface));
7329
7902
  if (!record) {
7330
7903
  problems.push(`.jig/critique/${surface}/tweak.json is missing. It names the change in the owner's words (\`change\`), when it was made (\`at\`) and the rule ids and decisions re-judged for it (\`ids\`).`);
7331
7904
  } else {
7332
7905
  if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
7333
7906
  if (!record.ids.length) problems.push(`.jig/critique/${surface}/tweak.json names nothing to re-judge. A change that no rule and no decision could see needs no tweak; name the ones it can.`);
7334
- const unjudged = unjudgedTweakIds(join39(root, ".jig", "critique", surface), record);
7907
+ if (record.deferred !== void 0) {
7908
+ if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
7909
+ problems.push(
7910
+ `.jig/critique/${surface}/tweak.json defers its re-judge, and nobody said to. \`deferred\` quotes the owner telling you to leave it for later, or is \`true\` where the page's spec says \`critique: at-ship\` (or, the spec silent, jig.config.json says \`"critique": "at-ship"\`). Otherwise re-judge what it names.`
7911
+ );
7912
+ }
7913
+ return problems;
7914
+ }
7915
+ const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
7335
7916
  if (unjudged.length) {
7336
7917
  problems.push(
7337
7918
  `.jig/critique/${surface}: tweak.json names ${unjudged.slice(0, 6).join(", ")}${unjudged.length > 6 ? " and more" : ""} but no verdict for ${unjudged.length === 1 ? "it" : "them"} carries \`"tweak": "${record.at}"\`. Each named verdict is re-judged on the changed page by a reader that did not make the change.`
@@ -7367,9 +7948,9 @@ function structureSinceApproval(root, spec, front) {
7367
7948
  problems.push(`${spec.path}: the regions under \`sizes:\` differ from the ones the page had ${when}. A change to what the page holds is not a tweak: take it through \`spec\` and \`mockup\`.`);
7368
7949
  }
7369
7950
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7370
- if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
7951
+ if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7371
7952
  try {
7372
- if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
7953
+ if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
7373
7954
  problems.push(`${at} has changed since ${when.replace(/^when /, "")}. A tweak leaves the drawing as approved; a change the drawing must show goes through \`mockup\`.`);
7374
7955
  }
7375
7956
  } catch {
@@ -7379,8 +7960,9 @@ function structureSinceApproval(root, spec, front) {
7379
7960
  }
7380
7961
  function readTweak(dir) {
7381
7962
  try {
7382
- const raw = JSON.parse(readFileSync32(join39(dir, "tweak.json"), "utf8"));
7383
- return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [] };
7963
+ const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
7964
+ const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
7965
+ return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
7384
7966
  } catch {
7385
7967
  return void 0;
7386
7968
  }
@@ -7391,19 +7973,37 @@ function surfacePage(projectRoot, surface) {
7391
7973
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7392
7974
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
7393
7975
  for (const candidate of candidates) {
7394
- if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
7976
+ if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
7395
7977
  }
7396
7978
  return void 0;
7397
7979
  }
7398
7980
  function gate(opts) {
7399
7981
  const root = opts.projectRoot;
7400
- if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
7982
+ if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
7401
7983
  return { block: false, reason: "" };
7402
7984
  }
7403
7985
  const invocation = lastJigInvocation(opts.input.transcript_path);
7404
7986
  const command = invocation?.command;
7405
7987
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7406
- const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
7988
+ const start = sessionStart(opts.input.transcript_path);
7989
+ const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
7990
+ const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
7991
+ if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
7992
+ if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
7993
+ const result = ship({ projectRoot: root, version: opts.version });
7994
+ if (!result.ready) problems.push(`/jig ship: the project is not ready to ship. Critique what is owed, fix what the critiques find or record the owner's ruling, and run \`jig ship\` again.
7995
+ ${result.report}`);
7996
+ }
7997
+ if (command === "make") {
7998
+ const spec = specFor(root, invocation?.surface);
7999
+ if (spec && mockupPending(spec.body) && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
8000
+ problems.push(`/jig make: ${spec.path} still says \`mockup: pending\`: nobody has said whether to draw it. Ask the owner, draw it with \`/jig mockup\` or skip it; if they skip it, write \`mockup: skipped \u2014 "<their words>"\` and build from the spec alone.`);
8001
+ }
8002
+ }
8003
+ if (waiting && command === "spec") {
8004
+ const spec = specFor(root, invocation?.surface);
8005
+ if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
8006
+ }
7407
8007
  const selection = selectFiles(root, false);
7408
8008
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7409
8009
  if (changedUi) {
@@ -7425,11 +8025,11 @@ function gate(opts) {
7425
8025
  }
7426
8026
  }
7427
8027
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
7428
- const critiqueDir = join39(root, ".jig", "critique");
7429
- if (existsSync24(critiqueDir)) {
8028
+ const critiqueDir = join42(root, ".jig", "critique");
8029
+ if (existsSync26(critiqueDir)) {
7430
8030
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
7431
- const dir = join39(critiqueDir, surface);
7432
- if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
8031
+ const dir = join42(critiqueDir, surface);
8032
+ if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
7433
8033
  const v = verifyVerdicts({ projectRoot: root, surface });
7434
8034
  if (!v.ok) {
7435
8035
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -7451,11 +8051,11 @@ ${problems.join("\n\n")}`
7451
8051
  };
7452
8052
  }
7453
8053
  const session = opts.input.session_id ?? "unknown";
7454
- const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
7455
- const stateFile = join39(root, ".jig", "gate.json");
8054
+ const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
8055
+ const stateFile = join42(root, ".jig", "gate.json");
7456
8056
  let state = {};
7457
8057
  try {
7458
- state = JSON.parse(readFileSync32(stateFile, "utf8"));
8058
+ state = JSON.parse(readFileSync35(stateFile, "utf8"));
7459
8059
  } catch {
7460
8060
  }
7461
8061
  if (problems.length === 0) {
@@ -7490,7 +8090,7 @@ function verdictDigests(dir) {
7490
8090
  for (const f of VERDICT_FILES) {
7491
8091
  let file;
7492
8092
  try {
7493
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8093
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7494
8094
  } catch {
7495
8095
  continue;
7496
8096
  }
@@ -7501,9 +8101,16 @@ function verdictDigests(dir) {
7501
8101
  }
7502
8102
  return out;
7503
8103
  }
7504
- function writeLock(lockPath, dir, now) {
8104
+ function writeLock(root, surface, lockPath, dir, now) {
8105
+ const pageFile2 = recordedPage(root, surface);
8106
+ let page;
7505
8107
  try {
7506
- writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
8108
+ if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
8109
+ } catch {
8110
+ }
8111
+ const tweakAt = readTweak(dir)?.at;
8112
+ try {
8113
+ writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
7507
8114
  } catch {
7508
8115
  }
7509
8116
  }
@@ -7511,7 +8118,7 @@ function verdictChecksum(dir) {
7511
8118
  const parts = VERDICT_FILES.map((f) => {
7512
8119
  try {
7513
8120
  return `${f}
7514
- ${readFileSync32(join39(dir, f), "utf8")}`;
8121
+ ${readFileSync35(join42(dir, f), "utf8")}`;
7515
8122
  } catch {
7516
8123
  return "";
7517
8124
  }
@@ -7519,22 +8126,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
7519
8126
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
7520
8127
  }
7521
8128
  function verdictGuard(root, command, inPlay) {
7522
- const critiqueDir = join39(root, ".jig", "critique");
7523
- if (!existsSync24(critiqueDir)) return [];
8129
+ const critiqueDir = join42(root, ".jig", "critique");
8130
+ if (!existsSync26(critiqueDir)) return [];
7524
8131
  const problems = [];
7525
- for (const surface of readdirSync15(critiqueDir)) {
8132
+ for (const surface of readdirSync16(critiqueDir)) {
7526
8133
  if (surface.startsWith("_")) continue;
7527
- const dir = join39(critiqueDir, surface);
8134
+ const dir = join42(critiqueDir, surface);
7528
8135
  const now = verdictChecksum(dir);
7529
8136
  if (!now) continue;
7530
- const lockPath = join39(dir, LOCK);
7531
- if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7532
- writeLock(lockPath, dir, now);
8137
+ const lockPath = join42(dir, LOCK);
8138
+ if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
8139
+ writeLock(root, surface, lockPath, dir, now);
8140
+ problems.push(...lockLeftBehind(root, surface));
7533
8141
  continue;
7534
8142
  }
7535
8143
  let lock = {};
7536
8144
  try {
7537
- lock = JSON.parse(readFileSync32(lockPath, "utf8"));
8145
+ lock = JSON.parse(readFileSync35(lockPath, "utf8"));
7538
8146
  } catch {
7539
8147
  continue;
7540
8148
  }
@@ -7542,7 +8150,10 @@ function verdictGuard(root, command, inPlay) {
7542
8150
  if (command === "tweak" && (!inPlay || inPlay.includes(surface)) && locked !== now) {
7543
8151
  const own = tweakVerdictProblems(dir, surface, lock.verdicts);
7544
8152
  problems.push(...own);
7545
- if (own.length === 0) writeLock(lockPath, dir, now);
8153
+ if (own.length === 0) {
8154
+ writeLock(root, surface, lockPath, dir, now);
8155
+ problems.push(...lockLeftBehind(root, surface));
8156
+ }
7546
8157
  continue;
7547
8158
  }
7548
8159
  if (locked && locked !== now) {
@@ -7553,6 +8164,17 @@ function verdictGuard(root, command, inPlay) {
7553
8164
  }
7554
8165
  return problems;
7555
8166
  }
8167
+ function lockLeftBehind(root, surface) {
8168
+ const rel = `.jig/critique/${surface}`;
8169
+ try {
8170
+ const status = (paths) => execFileSync4("git", ["status", "--porcelain", "--", ...paths], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
8171
+ if (!status([`${rel}/${LOCK}`])) return [];
8172
+ if (status(VERDICT_FILES.map((f) => `${rel}/${f}`))) return [];
8173
+ } catch {
8174
+ return [];
8175
+ }
8176
+ 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.`];
8177
+ }
7556
8178
  function tweakVerdictProblems(dir, surface, lockedVerdicts) {
7557
8179
  const record = readTweak(dir);
7558
8180
  if (!record) {
@@ -7577,7 +8199,7 @@ function unjudgedTweakIds(dir, record) {
7577
8199
  for (const f of VERDICT_FILES) {
7578
8200
  let file;
7579
8201
  try {
7580
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8202
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7581
8203
  } catch {
7582
8204
  continue;
7583
8205
  }
@@ -7589,113 +8211,22 @@ function unjudgedTweakIds(dir, record) {
7589
8211
  }
7590
8212
  function save(file, state) {
7591
8213
  try {
7592
- mkdirSync8(join39(file, ".."), { recursive: true });
8214
+ mkdirSync8(join42(file, ".."), { recursive: true });
7593
8215
  writeFileSync6(file, JSON.stringify(state), "utf8");
7594
8216
  } catch {
7595
8217
  }
7596
8218
  }
7597
8219
 
7598
- // src/commands/seo.ts
7599
- import { readFileSync as readFileSync33 } from "fs";
7600
- import { join as join40 } from "path";
7601
- var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7602
- var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7603
- var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
7604
- var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
7605
- var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
7606
- var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
7607
- var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
7608
- var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
7609
- function isPage(file, raw) {
7610
- if (TEST_FILE.test(file)) return false;
7611
- if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
7612
- const route = ROUTE_DIR.exec(file);
7613
- if (route) {
7614
- const rest = route[1];
7615
- const name = rest.split("/").pop();
7616
- if (name.startsWith("+")) return name === "+page.svelte";
7617
- if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
7618
- if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
7619
- if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
7620
- return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
7621
- }
7622
- return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
7623
- }
7624
- function routeOf(file) {
7625
- const path = file.replace(/^(?:src\/pages|app\/pages|app\/routes|src\/routes|pages|routes|app|src|public|content)\//, "").replace(/(^|\/)(index|\+?page)\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "$1").replace(/\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "").replace(/\/$/, "");
7626
- return `/${path}`.replace(/\/+/g, "/");
7627
- }
7628
- function seo(opts) {
7629
- const root = opts.projectRoot;
7630
- const files = selectFiles(root, true).files;
7631
- const findings = [];
7632
- const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
7633
- const pages = [];
7634
- let sitemapFiles = [];
7635
- let robotsFiles = [];
7636
- const sitemapRoutes = /* @__PURE__ */ new Map();
7637
- const pathLocs = [];
7638
- for (const file of files) {
7639
- let raw;
7640
- try {
7641
- raw = readFileSync33(join40(root, file), "utf8");
7642
- } catch {
7643
- continue;
7644
- }
7645
- if (SITEMAP_FILE.test(file)) {
7646
- sitemapFiles.push(file);
7647
- for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
7648
- const value = (match[1] ?? match[2] ?? "").trim();
7649
- if (!value) continue;
7650
- if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
7651
- const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
7652
- sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
7653
- }
7654
- continue;
7655
- }
7656
- if (ROBOTS_FILE.test(file)) {
7657
- robotsFiles.push(file);
7658
- continue;
7659
- }
7660
- if (!isPage(file, raw)) continue;
7661
- const found = readMetadata(raw);
7662
- const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
7663
- pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
7664
- }
7665
- for (const page of pages.filter((p) => p.noindex)) {
7666
- const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
7667
- if (listed) {
7668
- add(listed, "J-124", "error", `the sitemap lists ${page.route}, and ${page.file} says noindex. One answer per route: a crawler reading two picks one.`);
7669
- }
7670
- }
7671
- if (pathLocs.length > 0) {
7672
- const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
7673
- add(pathLocs[0].file, "J-124", "error", `${pathLocs.length} sitemap entr${pathLocs.length === 1 ? "y is a path" : "ies are paths"} (${shown}), not a full URL. A crawler drops them; with no origin yet, no sitemap is the honest one.`);
7674
- }
7675
- const byTitle = /* @__PURE__ */ new Map();
7676
- for (const page of pages) {
7677
- if (!page.title || page.noindex) continue;
7678
- byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
7679
- }
7680
- for (const [title, where] of byTitle) {
7681
- if (where.length > 1) {
7682
- add(where[1], "J-121", "warning", `"${title}" is the title of ${where.length} pages (${where.join(", ")}). A result page showing the same line twice tells a reader nothing about either.`);
7683
- }
7684
- }
7685
- const indexable = pages.filter((p) => !p.noindex);
7686
- const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
7687
- if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
7688
- add(sitemapFiles[0], "J-127", "error", "the sitemap lists no routes. An empty sitemap is not a missing one: it is a claim that the site has nothing.");
7689
- }
7690
- const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
7691
- const line = `JIG_SEO: pages=${pages.length} indexable=${indexable.length} noindex=${pages.length - indexable.length} sitemap=${sitemapFiles.length > 0 ? sitemapRoutes.size : "none"} robots=${robotsFiles.length > 0 ? "yes" : "no"} overlong=${titleBudget} findings=${findings.length}`;
7692
- return { findings, pages: pages.length, indexable: indexable.length, line };
7693
- }
7694
-
7695
8220
  // src/index.ts
7696
8221
  var packageRoot = getPackageRoot();
7697
- var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
8222
+ var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
7698
8223
  function warnIfUnpublishedPin() {
8224
+ if (isDevVersion(version)) {
8225
+ console.warn(
8226
+ `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.`
8227
+ );
8228
+ return;
8229
+ }
7699
8230
  if (isPublishedBuild(packageRoot)) return;
7700
8231
  console.warn(
7701
8232
  `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 +8350,7 @@ program.command("verdicts").description("Verify a critique's verdict files and c
7819
8350
  const p = result.previous;
7820
8351
  if (p) {
7821
8352
  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)}.`);
8353
+ 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
8354
  }
7824
8355
  console.log(` ${result.line}`);
7825
8356
  process.exit(result.ok ? 0 : 1);
@@ -7869,6 +8400,17 @@ program.command("seo").description("Audit what a search engine and a link previe
7869
8400
  process.exit(1);
7870
8401
  }
7871
8402
  });
8403
+ program.command("ship").description("Say whether the project is ready to ship: no mechanical or seo errors, and every confirmed page judged as it stands with nothing open.").action(() => {
8404
+ const projectRoot = findProjectRoot(process.cwd());
8405
+ try {
8406
+ const result = ship({ projectRoot, version });
8407
+ console.log(result.report);
8408
+ process.exit(result.ready ? 0 : 1);
8409
+ } catch (err) {
8410
+ console.error(err.message);
8411
+ process.exit(1);
8412
+ }
8413
+ });
7872
8414
  program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600 and either side of each --breakpoint-* the project declares, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
7873
8415
  if (opts.run) {
7874
8416
  if (!opts.save) {
@@ -7911,13 +8453,14 @@ program.command("probe").description("Print the render probe. With --save, read
7911
8453
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
7912
8454
  let input = {};
7913
8455
  try {
7914
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
8456
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
7915
8457
  } catch {
7916
8458
  }
7917
8459
  const cwd = input.cwd ?? process.cwd();
7918
8460
  try {
7919
8461
  const projectRoot = findProjectRoot(cwd);
7920
- for (const surface of critiquedSurfaces(projectRoot)) {
8462
+ const critiqued = critiquedSurfaces(projectRoot);
8463
+ for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
7921
8464
  const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
7922
8465
  if (!page) continue;
7923
8466
  try {
@@ -7950,7 +8493,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
7950
8493
  });
7951
8494
  function resolveSurfaceMode(projectRoot) {
7952
8495
  try {
7953
- const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
8496
+ const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
7954
8497
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
7955
8498
  return modes.length === 1 ? modes[0] : void 0;
7956
8499
  } catch {