jig-ui 0.21.0 → 0.23.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";
@@ -28,7 +28,7 @@ function assetRoot(startDir = getPackageRoot()) {
28
28
  let current = startDir;
29
29
  const { root } = parse(startDir);
30
30
  while (true) {
31
- if (existsSync(join(current, ASSET_MARKER))) return current;
31
+ if (existsSync(join(current, ASSET_MARKER))) return sourceOf(current);
32
32
  if (current === root) {
33
33
  throw new Error(
34
34
  `assetRoot(): could not find an ancestor of "${startDir}" containing "${ASSET_MARKER}"`
@@ -37,6 +37,11 @@ function assetRoot(startDir = getPackageRoot()) {
37
37
  current = dirname(current);
38
38
  }
39
39
  }
40
+ function sourceOf(found) {
41
+ if (isPublishedBuild(found)) return found;
42
+ const repo = join(found, "..", "..");
43
+ return existsSync(join(repo, ASSET_MARKER)) && existsSync(join(repo, "packages", "cli", "package.json")) ? repo : found;
44
+ }
40
45
  function isPublishedBuild(packageRoot2) {
41
46
  return packageRoot2.split(/[\\/]/).includes("node_modules");
42
47
  }
@@ -1522,8 +1527,9 @@ Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : re
1522
1527
  const idBody = ids.filter((e) => !idTitle.includes(e) && matches(e.text, needle));
1523
1528
  const secTitle = secs.filter((e) => matches(e.title, needle));
1524
1529
  const secBody = secs.filter((e) => !secTitle.includes(e) && matches(e.text, needle));
1530
+ const opens = (e) => e.title.toLowerCase().startsWith(needle.toLowerCase()) ? 0 : 1;
1525
1531
  const hits = [
1526
- ...idTitle.sort((a, b) => byNumber(a.id, b.id)),
1532
+ ...idTitle.sort((a, b) => opens(a) - opens(b) || byNumber(a.id, b.id)),
1527
1533
  ...idBody.sort((a, b) => byNumber(a.id, b.id)),
1528
1534
  ...secTitle,
1529
1535
  ...secBody
@@ -3505,19 +3511,21 @@ function indentedProse(source) {
3505
3511
  const blank3 = (m) => " ".repeat(m.length);
3506
3512
  return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
3507
3513
  }
3514
+ function readerSpans(file, raw) {
3515
+ const markdown = MARKDOWN.test(file);
3516
+ const indented = INDENTED.test(file);
3517
+ const masked = markdown ? markdownProse(raw) : indented ? indentedProse(maskNonProse(raw)) : maskNonProse(raw);
3518
+ const script = SCRIPT.test(file);
3519
+ return markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
3520
+ }
3508
3521
  var emDash = {
3509
3522
  name: "em-dash",
3510
3523
  appliesTo: (file) => isReaderText(file),
3511
3524
  run(_source, file, ctx) {
3512
- const markdown = MARKDOWN.test(file);
3513
- const indented = INDENTED.test(file);
3514
- const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
3515
3525
  const starts = buildLineIndex(ctx.raw);
3516
3526
  const findings = [];
3517
3527
  const seen = /* @__PURE__ */ new Set();
3518
- const script = SCRIPT.test(file);
3519
- const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
3520
- for (const span of spans) {
3528
+ for (const span of readerSpans(file, ctx.raw)) {
3521
3529
  if (!EM_DASH.test(span.text)) continue;
3522
3530
  for (const hit of span.text.matchAll(/\u2014/g)) {
3523
3531
  const line = lineForOffset(starts, span.index + hit.index);
@@ -3539,6 +3547,135 @@ var emDash = {
3539
3547
  }
3540
3548
  };
3541
3549
 
3550
+ // src/check/detectors/prose.ts
3551
+ var FORMULA = new RegExp(
3552
+ [
3553
+ String.raw`in today['’]s (?:fast[- ]paced|rapidly|ever[- ]|increasingly|digital|modern|busy)`,
3554
+ String.raw`in an (?:increasingly|ever[- ](?:changing|evolving)) \w+ (?:world|landscape|age|marketplace|era)`,
3555
+ String.raw`as technology continues to evolve`,
3556
+ String.raw`it(?: is|['’]s) (?:important|worth) (?:to note|noting)`,
3557
+ String.raw`let['’]s (?:dive|delve)\b`,
3558
+ String.raw`here['’]s the thing\b`,
3559
+ String.raw`without further ado`,
3560
+ String.raw`in conclusion\b`,
3561
+ String.raw`only time will tell`,
3562
+ String.raw`the future (?:looks|is) (?:bright|incredibly promising)`
3563
+ ].map((p) => `\\b${p}`).join("|"),
3564
+ "gi"
3565
+ );
3566
+ function maskQuotations(raw) {
3567
+ const blank3 = (m) => m.replace(/[^\n]/g, " ");
3568
+ return raw.replace(/<blockquote\b[\s\S]*?<\/blockquote\s*>/gi, blank3).replace(/<q\b[\s\S]*?<\/q\s*>/gi, blank3).replace(/^ {0,3}>[^\n]*/gm, blank3);
3569
+ }
3570
+ var formulaPhrase = {
3571
+ name: "formula-phrase",
3572
+ appliesTo: (file) => isReaderText(file),
3573
+ run(_source, file, ctx) {
3574
+ const starts = buildLineIndex(ctx.raw);
3575
+ const findings = [];
3576
+ const seen = /* @__PURE__ */ new Set();
3577
+ for (const span of readerSpans(file, maskQuotations(ctx.raw))) {
3578
+ for (const hit of span.text.matchAll(FORMULA)) {
3579
+ const line = lineForOffset(starts, span.index + hit.index);
3580
+ if (seen.has(line)) continue;
3581
+ seen.add(line);
3582
+ findings.push(
3583
+ mkFinding(
3584
+ ctx,
3585
+ "formula-phrase",
3586
+ file,
3587
+ line,
3588
+ `a formula phrase ("${hit[0]}"): start with the point, and end when it has been made`,
3589
+ sourceLine(ctx.raw, line)
3590
+ )
3591
+ );
3592
+ }
3593
+ }
3594
+ return findings.sort((a, b) => a.line - b.line);
3595
+ }
3596
+ };
3597
+ var HEADING3 = /^ {0,3}#{1,6}[ \t]+(.+)$/gm;
3598
+ var FENCE = /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm;
3599
+ var headingEmoji = {
3600
+ name: "heading-emoji",
3601
+ appliesTo: (file) => isReaderText(file) && hasExtension(file, [".md", ".markdown"]) && !isStyleBearing(file),
3602
+ run(_source, file, ctx) {
3603
+ const masked = ctx.raw.replace(FENCE, (m) => m.replace(/[^\n]/g, " "));
3604
+ const starts = buildLineIndex(ctx.raw);
3605
+ const findings = [];
3606
+ for (const heading of masked.matchAll(HEADING3)) {
3607
+ const glyph = [...heading[1].matchAll(EMOJI_RE)].find((m) => !TEXTUAL.has(m[0]));
3608
+ if (!glyph) continue;
3609
+ const line = lineForOffset(starts, heading.index);
3610
+ findings.push(
3611
+ mkFinding(
3612
+ ctx,
3613
+ "heading-emoji",
3614
+ file,
3615
+ line,
3616
+ `an emoji in a heading ("${glyph[0]}"): let the words carry it; a screen reader reads the emoji's name first`,
3617
+ sourceLine(ctx.raw, line)
3618
+ )
3619
+ );
3620
+ }
3621
+ return findings;
3622
+ }
3623
+ };
3624
+
3625
+ // src/check/detectors/layout-motion.ts
3626
+ var LAYOUT = String.raw`(?:(?:min-|max-)?(?:width|height)|(?:inline|block)-size|top|left|right|bottom|inset(?:-[a-z-]+)?|margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?)`;
3627
+ var LAYOUT_NAME = new RegExp(`^${LAYOUT}$`, "i");
3628
+ var TRANSITION = /(?<![-\w])transition(-property)?\s*:\s*([^;}]+)/gi;
3629
+ var KEYFRAMES = /@(?:-webkit-)?keyframes\b[^{]*\{/gi;
3630
+ var KEYFRAME_DECL = new RegExp(`(?<![-\\w])(${LAYOUT})\\s*:`, "gi");
3631
+ var TAILWIND = new RegExp(`(?<![-\\w\\[])transition-(all|\\[[^\\]\\s]*?\\b${LAYOUT}\\b[^\\]\\s]*\\])(?![-\\w])`, "gi");
3632
+ function namedProperties(value, longhand) {
3633
+ return value.split(",").map((item) => {
3634
+ const words = item.trim().split(/\s+/);
3635
+ return longhand ? words[0] : words.find((w) => /^[a-z-]+$/i.test(w) && !/^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|allow-discrete|normal|none)$/i.test(w)) ?? "";
3636
+ }).filter(Boolean);
3637
+ }
3638
+ function blockEnd(source, open) {
3639
+ let depth = 0;
3640
+ for (let i = open; i < source.length; i++) {
3641
+ if (source[i] === "{") depth++;
3642
+ else if (source[i] === "}" && --depth === 0) return i;
3643
+ }
3644
+ return source.length;
3645
+ }
3646
+ var layoutMotion = {
3647
+ name: "layout-motion",
3648
+ appliesTo: (file) => isStyleBearing(file),
3649
+ run(source, file, ctx) {
3650
+ const findings = [];
3651
+ const seen = /* @__PURE__ */ new Set();
3652
+ const report2 = (text, offset, message) => {
3653
+ const line = lineForOffset(buildLineIndex(text), offset);
3654
+ if (seen.has(line)) return;
3655
+ seen.add(line);
3656
+ findings.push(mkFinding(ctx, "layout-motion", file, line, message, sourceLine(text, line)));
3657
+ };
3658
+ for (const m of source.matchAll(TRANSITION)) {
3659
+ const names = namedProperties(m[2], Boolean(m[1]));
3660
+ const bad = names.find((n) => n.toLowerCase() === "all" || LAYOUT_NAME.test(n));
3661
+ if (!bad) continue;
3662
+ report2(source, m.index, bad.toLowerCase() === "all" ? "a transition on `all`: name the properties it covers, and animate transform and opacity" : `a transition on \`${bad}\`, which lays the page out again every frame: animate transform or opacity instead`);
3663
+ }
3664
+ for (const k of source.matchAll(KEYFRAMES)) {
3665
+ const open = k.index + k[0].length - 1;
3666
+ const body = source.slice(open, blockEnd(source, open));
3667
+ for (const d of body.matchAll(KEYFRAME_DECL)) {
3668
+ report2(source, open + d.index, `a keyframe animates \`${d[1]}\`, which lays the page out again every frame: animate transform or opacity instead`);
3669
+ }
3670
+ }
3671
+ const raw = maskProseComments(ctx.raw);
3672
+ for (const t of raw.matchAll(TAILWIND)) {
3673
+ report2(ctx.raw, t.index, `\`${t[0]}\` animates ${t[1] === "all" ? "every property that changes" : "layout"}: use \`transition\`, \`transition-opacity\` or \`transition-transform\``);
3674
+ }
3675
+ return findings.sort((a, b) => a.line - b.line);
3676
+ }
3677
+ };
3678
+
3542
3679
  // src/check/detectors/semantic-element.ts
3543
3680
  var BODY = /<body\b/i;
3544
3681
  var MAIN = /<main\b|role\s*=\s*["']main["']/i;
@@ -4094,6 +4231,9 @@ var DETECTORS = [
4094
4231
  menuState,
4095
4232
  undeclaredToken,
4096
4233
  emDash,
4234
+ formulaPhrase,
4235
+ headingEmoji,
4236
+ layoutMotion,
4097
4237
  semanticElement,
4098
4238
  metadata,
4099
4239
  interfaceSafety,
@@ -5349,6 +5489,7 @@ async function init(opts) {
5349
5489
  log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
5350
5490
  for (const line of describeValidation(validation)) log(line);
5351
5491
  let surfaces = DEFAULT_SURFACES;
5492
+ let critique;
5352
5493
  if (!opts.yes) {
5353
5494
  for (let attempt = 0; attempt < 3; attempt++) {
5354
5495
  const answer = await prompt(
@@ -5376,6 +5517,12 @@ async function init(opts) {
5376
5517
  ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
5377
5518
  );
5378
5519
  }
5520
+ const critiqueAnswer = (await prompt(
5521
+ `When should pages be critiqued? Enter to decide page by page, 'each' after every build, or 'ship' to leave them for jig ship: `
5522
+ )).toLowerCase();
5523
+ if (/^(e|each)$/.test(critiqueAnswer)) critique = "each";
5524
+ else if (/^(s|ship|at-ship)$/.test(critiqueAnswer)) critique = "at-ship";
5525
+ else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
5379
5526
  }
5380
5527
  if (opts.yes) {
5381
5528
  const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
@@ -5483,7 +5630,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5483
5630
  }
5484
5631
  }
5485
5632
  if (configAction === "written") {
5486
- const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
5633
+ const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
5487
5634
  const content = `${JSON.stringify(config, null, 2)}
5488
5635
  `;
5489
5636
  writeFileSync4(configAbsPath, content, "utf8");
@@ -5499,6 +5646,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5499
5646
  const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5500
5647
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5501
5648
  const merged = {
5649
+ ...existing,
5650
+ ...existing.critique === void 0 && critique ? { critique } : {},
5502
5651
  brand: brandStillResolves ? existingBrand : brandRelPath,
5503
5652
  // Same contract as the 'written' branch: never invent a mapping under
5504
5653
  // `--yes`. An existing declaration is always kept.
@@ -6516,7 +6665,7 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6516
6665
  // src/check/decisions.ts
6517
6666
  import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
6518
6667
  import { join as join35, posix as posix3 } from "path";
6519
- var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
6668
+ var HEADING4 = /^(#{2,3})\s+(.+?)\s*$/gm;
6520
6669
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6521
6670
  function decisionsFile(projectRoot) {
6522
6671
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
@@ -6543,7 +6692,7 @@ function decisionHeadings(projectRoot) {
6543
6692
  } catch {
6544
6693
  return headings;
6545
6694
  }
6546
- for (const match of body.matchAll(HEADING3)) {
6695
+ for (const match of body.matchAll(HEADING4)) {
6547
6696
  const name = match[2].replace(/[`*]/g, "").trim();
6548
6697
  if (!name || NOT_A_DECISION.test(name)) continue;
6549
6698
  if (!headings.has(name)) headings.set(name, match[0].trim());
@@ -6598,14 +6747,12 @@ function unsourcedReasons(current, before, opts = {}) {
6598
6747
  return problems;
6599
6748
  }
6600
6749
  function quotesNotFrom(current, before, words) {
6601
- const norm = (t) => t.toLowerCase().replace(/[‘’]/g, "'").replace(/\s+/g, " ").trim();
6602
- const said = norm(words);
6603
6750
  const problems = [];
6604
6751
  for (const [name, whys] of changedWhys(current, before, true)) {
6605
6752
  for (const why of whys) {
6606
6753
  const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6607
6754
  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)));
6755
+ const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
6609
6756
  if (missing.length) {
6610
6757
  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
6758
  break;
@@ -6614,6 +6761,11 @@ function quotesNotFrom(current, before, words) {
6614
6761
  }
6615
6762
  return problems;
6616
6763
  }
6764
+ var normQuote = (t) => t.toLowerCase().replace(/\\(?=["'\\])/g, "").replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, " ").trim();
6765
+ function quoteHeld(quote, words) {
6766
+ const said = normQuote(words);
6767
+ return quote.split(/…|\.\.\./).map((part) => normQuote(part).replace(/^[\s.,;:]+|[\s.,;:!?]+$/g, "")).filter(Boolean).every((part) => said.includes(part));
6768
+ }
6617
6769
 
6618
6770
  // src/check/spec-shape.ts
6619
6771
  import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
@@ -6644,6 +6796,22 @@ function specProblems(spec) {
6644
6796
  if (specIndexableField(front) === "unreadable") {
6645
6797
  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.`);
6646
6798
  }
6799
+ const critique = /^\s*critique\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
6800
+ if (critique !== void 0 && !/^(each|at-ship)$/i.test(critique)) {
6801
+ problems.push(`${spec.path}: \`critique: ${critique}\` is neither \`each\` nor \`at-ship\`. Leave it out to follow the project's default.`);
6802
+ }
6803
+ const motion = /^motion\s*:[ \t]*(.*)\n?((?:[ \t]+-.*\n?)*)/im.exec(front);
6804
+ if (motion) {
6805
+ const inline = motion[1].replace(/\s+#.*$/, "").trim();
6806
+ const items = motion[2].split("\n").map((l) => l.replace(/^[ \t]+-\s*/, "").trim()).filter(Boolean);
6807
+ if (!items.length && !/^none$/i.test(inline)) {
6808
+ problems.push(`${spec.path}: \`motion:\` is neither \`none\` nor a list. List each movement as \`- <what moves>: <how>, on <trigger>; tells <what the reader learns>\`, or write \`motion: none\`.`);
6809
+ }
6810
+ const unsaid = items.filter((i) => !/\bon\b/i.test(i) || !/\btells?\b/i.test(i));
6811
+ if (unsaid.length) {
6812
+ problems.push(`${spec.path}: ${unsaid.length === 1 ? "a movement in `motion:` does" : `${unsaid.length} movements in \`motion:\` do`} not say what triggers it and what it tells the reader ("${unsaid[0]}"). Write \`on <trigger>; tells <what>\`, or cut the movement.`);
6813
+ }
6814
+ }
6647
6815
  for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6648
6816
  if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6649
6817
  }
@@ -6787,7 +6955,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6787
6955
  seen.add(id);
6788
6956
  const pass = otherPass.get(id);
6789
6957
  if (!required.includes(id) && !extraAllowed.has(id)) {
6790
- errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
6958
+ errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict. A difference from the spec, or from a source the page quotes, is not a rule: list it under \`differences\` in the same file.`);
6791
6959
  continue;
6792
6960
  }
6793
6961
  if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
@@ -6808,6 +6976,16 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6808
6976
  if (v.verdict === "finding") findings++;
6809
6977
  }
6810
6978
  reasonProblems(`${name}.json`, reasons, errors);
6979
+ const differences = file.differences;
6980
+ if (differences !== void 0) {
6981
+ if (!Array.isArray(differences)) errors.push(`${name}.json: \`differences\` is not a list.`);
6982
+ else for (const d of differences) {
6983
+ const said = (x) => typeof x === "string" && x.trim() !== "";
6984
+ if (!said(d?.what) || !said(d?.where) || !said(d?.against)) {
6985
+ errors.push(`${name}.json: a difference needs \`what\` (what differs), \`where\` (the page's file and line) and \`against\` (the spec line or source it differs from).`);
6986
+ } else findings++;
6987
+ }
6988
+ }
6811
6989
  const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
6812
6990
  if (missing.length) {
6813
6991
  errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
@@ -6847,7 +7025,7 @@ function checkDecisions(projectRoot, dir, errors) {
6847
7025
  }
6848
7026
  seen.add(match);
6849
7027
  if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
6850
- errors.push(`decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
7028
+ errors.push(v.verdict === "ruled" ? `decisions.json: "${match}" is marked ruled. A decision is not excused by a decision: judge whether the page follows it, ok, finding or n/a. \`ruled\` is for a rule in screen.json or code.json that a decision overrides.` : `decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
6851
7029
  continue;
6852
7030
  }
6853
7031
  const reason = typeof v.reason === "string" ? v.reason.trim() : "";
@@ -7053,9 +7231,9 @@ function sourceChanges(git, from, to) {
7053
7231
  }
7054
7232
 
7055
7233
  // src/commands/gate.ts
7056
- import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7057
- import { createHash as createHash2 } from "crypto";
7058
- import { join as join39 } from "path";
7234
+ import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7235
+ import { createHash as createHash3 } from "crypto";
7236
+ import { join as join42 } from "path";
7059
7237
 
7060
7238
  // src/check/mockup-drawing.ts
7061
7239
  import { readFileSync as readFileSync31 } from "fs";
@@ -7229,12 +7407,325 @@ function sizeBlock2(sizes, size) {
7229
7407
 
7230
7408
  // src/commands/gate.ts
7231
7409
  import { execFileSync as execFileSync4 } from "child_process";
7410
+
7411
+ // src/commands/ship.ts
7412
+ import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
7413
+ import { join as join40 } from "path";
7414
+
7415
+ // src/commands/seo.ts
7416
+ import { readFileSync as readFileSync32 } from "fs";
7417
+ import { join as join39 } from "path";
7418
+ var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7419
+ var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7420
+ var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
7421
+ var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
7422
+ var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
7423
+ var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
7424
+ var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
7425
+ var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
7426
+ function isPage(file, raw) {
7427
+ if (TEST_FILE.test(file)) return false;
7428
+ if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
7429
+ const route = ROUTE_DIR.exec(file);
7430
+ if (route) {
7431
+ const rest = route[1];
7432
+ const name = rest.split("/").pop();
7433
+ if (name.startsWith("+")) return name === "+page.svelte";
7434
+ if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
7435
+ if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
7436
+ if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
7437
+ return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
7438
+ }
7439
+ return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
7440
+ }
7441
+ function routeOf(file) {
7442
+ 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(/\/$/, "");
7443
+ return `/${path}`.replace(/\/+/g, "/");
7444
+ }
7445
+ function seo(opts) {
7446
+ const root = opts.projectRoot;
7447
+ const files = selectFiles(root, true).files;
7448
+ const findings = [];
7449
+ const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
7450
+ const pages = [];
7451
+ let sitemapFiles = [];
7452
+ let robotsFiles = [];
7453
+ const sitemapRoutes = /* @__PURE__ */ new Map();
7454
+ const pathLocs = [];
7455
+ for (const file of files) {
7456
+ let raw;
7457
+ try {
7458
+ raw = readFileSync32(join39(root, file), "utf8");
7459
+ } catch {
7460
+ continue;
7461
+ }
7462
+ if (SITEMAP_FILE.test(file)) {
7463
+ sitemapFiles.push(file);
7464
+ for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
7465
+ const value = (match[1] ?? match[2] ?? "").trim();
7466
+ if (!value) continue;
7467
+ if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
7468
+ const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
7469
+ sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
7470
+ }
7471
+ continue;
7472
+ }
7473
+ if (ROBOTS_FILE.test(file)) {
7474
+ robotsFiles.push(file);
7475
+ continue;
7476
+ }
7477
+ if (!isPage(file, raw)) continue;
7478
+ const found = readMetadata(raw);
7479
+ const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
7480
+ pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
7481
+ }
7482
+ for (const page of pages.filter((p) => p.noindex)) {
7483
+ const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
7484
+ if (listed) {
7485
+ 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.`);
7486
+ }
7487
+ }
7488
+ if (pathLocs.length > 0) {
7489
+ const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
7490
+ 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.`);
7491
+ }
7492
+ const byTitle = /* @__PURE__ */ new Map();
7493
+ for (const page of pages) {
7494
+ if (!page.title || page.noindex) continue;
7495
+ byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
7496
+ }
7497
+ for (const [title, where] of byTitle) {
7498
+ if (where.length > 1) {
7499
+ 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.`);
7500
+ }
7501
+ }
7502
+ const indexable = pages.filter((p) => !p.noindex);
7503
+ const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
7504
+ if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
7505
+ 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.");
7506
+ }
7507
+ const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
7508
+ 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}`;
7509
+ return { findings, pages: pages.length, indexable: indexable.length, line };
7510
+ }
7511
+
7512
+ // src/commands/ship.ts
7513
+ var NOT_COVERED = "security, performance, what a screen reader or a real device does, and deployment itself";
7514
+ function specCritique(specBody) {
7515
+ const front = specBody?.split(/^---\s*$/m)[1] ?? "";
7516
+ const value = /^\s*critique\s*:\s*([\w-]+)/im.exec(front)?.[1]?.toLowerCase();
7517
+ return value === "each" || value === "at-ship" ? value : void 0;
7518
+ }
7519
+ function critiqueAtShip(projectRoot, specBody) {
7520
+ const own = specCritique(specBody);
7521
+ if (own) return own === "at-ship";
7522
+ try {
7523
+ return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7524
+ } catch {
7525
+ return false;
7526
+ }
7527
+ }
7528
+ function tweakDeferred(dir) {
7529
+ let t;
7530
+ try {
7531
+ t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
7532
+ } catch {
7533
+ return false;
7534
+ }
7535
+ if (t.deferred === void 0 || t.deferred === false) return false;
7536
+ let judged;
7537
+ try {
7538
+ judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
7539
+ } catch {
7540
+ }
7541
+ return judged !== t.at;
7542
+ }
7543
+ function pageChecksum(html) {
7544
+ return checksum(html.replace(/(href|src)="[^"]*\.(css|js|mjs)(\?[^"]*)?"/g, '$1="asset.$2"'));
7545
+ }
7546
+ function changedSinceLock(projectRoot, dir) {
7547
+ let lock;
7548
+ try {
7549
+ lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
7550
+ } catch {
7551
+ return void 0;
7552
+ }
7553
+ const file = lock.page?.file;
7554
+ if (!file || !lock.page?.checksum) return void 0;
7555
+ try {
7556
+ const html = readFileSync33(join40(projectRoot, file), "utf8");
7557
+ return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
7558
+ } catch {
7559
+ return void 0;
7560
+ }
7561
+ }
7562
+ function supersededBy(front) {
7563
+ const named = /^\s*superseded_by\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
7564
+ return named ? named : void 0;
7565
+ }
7566
+ function pageStatus(projectRoot, surface) {
7567
+ const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
7568
+ const front = spec.split(/^---\s*$/m)[1] ?? "";
7569
+ if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
7570
+ return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
7571
+ }
7572
+ const successor = supersededBy(front);
7573
+ if (successor !== void 0) {
7574
+ const file = successor.replace(/(\.spec\.md)?$/, ".spec.md");
7575
+ return existsSync24(join40(projectRoot, ".jig", "specs", file)) ? { surface, state: "superseded", detail: `superseded by ${file}, whose critique judges its page` } : { surface, state: "incomplete", detail: `\`superseded_by: ${successor}\` names a spec that does not exist in .jig/specs` };
7576
+ }
7577
+ const dir = join40(projectRoot, ".jig", "critique", surface);
7578
+ if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
7579
+ return { surface, state: "never", detail: "never critiqued" };
7580
+ }
7581
+ if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
7582
+ const changed = changedSinceLock(projectRoot, dir);
7583
+ if (changed) return { surface, state: "changed", detail: `${changed} changed after it was judged` };
7584
+ const v = verifyVerdicts({ projectRoot, surface });
7585
+ const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
7586
+ let judgedPage = false;
7587
+ try {
7588
+ judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
7589
+ } catch {
7590
+ }
7591
+ if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
7592
+ 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\`)` };
7593
+ if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
7594
+ const findings = v.screen.findings + v.code.findings + v.decisions.findings;
7595
+ if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
7596
+ return { surface, state: "judged", detail: "judged as it stands, nothing open" };
7597
+ }
7598
+ function ship(opts) {
7599
+ const root = opts.projectRoot;
7600
+ const mechanical = check({ projectRoot: root, homeDir: "", version: opts.version, all: true, ci: true });
7601
+ const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
7602
+ const seoResult = seo({ projectRoot: root });
7603
+ const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
7604
+ const specsDir = join40(root, ".jig", "specs");
7605
+ const surfaces = existsSync24(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
7606
+ const pages = surfaces.map((s) => pageStatus(root, s));
7607
+ const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
7608
+ const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0;
7609
+ const count = (state) => pages.filter((p) => p.state === state).length;
7610
+ 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")} superseded=${count("superseded")}`;
7611
+ const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7", superseded: "\xB7" };
7612
+ const report2 = [
7613
+ ` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
7614
+ ` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
7615
+ ...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
7616
+ 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.` : "",
7617
+ ` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
7618
+ ` ${line}`
7619
+ ].filter(Boolean).join("\n");
7620
+ return { ready, mechanicalErrors, seoErrors, pages, line, report: report2 };
7621
+ }
7622
+
7623
+ // src/check/spec-checked.ts
7624
+ import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
7625
+ import { createHash as createHash2 } from "crypto";
7626
+ import { join as join41 } from "path";
7627
+ function specChecksum(body) {
7628
+ return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7629
+ }
7630
+ var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7631
+ function mockupWordProblems(spec, then, owner) {
7632
+ const now = mockupLine(spec.body);
7633
+ if (now === mockupLine(then)) return [];
7634
+ const m = /^(approved|skipped)\b(.*)$/i.exec(now);
7635
+ if (!m) return [];
7636
+ const word = m[1].toLowerCase();
7637
+ const quoted = /["“]([^"”]+)["”]/.exec(m[2])?.[1]?.trim();
7638
+ if (!quoted) {
7639
+ 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"}.`];
7640
+ }
7641
+ if (!quoteHeld(quoted, owner)) {
7642
+ 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.`];
7643
+ }
7644
+ return [];
7645
+ }
7646
+ function mockupPending(body) {
7647
+ const now = mockupLine(body);
7648
+ return !now || /^pending\b/i.test(now);
7649
+ }
7650
+ function ownerQuotes(body) {
7651
+ const quote = String.raw`\\?["“]((?:[^"“”\\\n]|\\(?!"))+?)\\?["”]`;
7652
+ const patterns = [
7653
+ new RegExp(String.raw`\bowner(?:['’]s)?\b[^"“”\n]{0,30}?` + quote, "gi"),
7654
+ new RegExp(String.raw`\buser(?:['’]s words)?\s*(?:(?:said|says|ruled|wrote|asked|answered|replied)\s*[:,]?|:)\s*` + quote, "gi")
7655
+ ];
7656
+ const out = [];
7657
+ for (const pattern of patterns) {
7658
+ for (const m of body.matchAll(pattern)) {
7659
+ const q = m[1].trim();
7660
+ if (q.length >= 3 && !/^not given\b/i.test(q) && !out.includes(q)) out.push(q);
7661
+ }
7662
+ }
7663
+ return out;
7664
+ }
7665
+ function specCheckProblems(root, spec, then, owner, decisions) {
7666
+ if (spec.body === then) return [];
7667
+ const problems = [];
7668
+ const held = new Set(ownerQuotes(then));
7669
+ const sources = [owner, then, decisions].join("\n");
7670
+ const unheld = ownerQuotes(spec.body).filter((q) => !held.has(q) && !quoteHeld(q, sources));
7671
+ if (unheld.length) {
7672
+ problems.push(
7673
+ `${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.`
7674
+ );
7675
+ }
7676
+ const recordPath = `.jig/specs/${spec.slug}.checked.json`;
7677
+ let record;
7678
+ try {
7679
+ record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
7680
+ } catch {
7681
+ problems.push(
7682
+ `${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.`
7683
+ );
7684
+ return problems;
7685
+ }
7686
+ if (record.spec !== specChecksum(spec.body)) {
7687
+ 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.`);
7688
+ }
7689
+ if (!Array.isArray(record.quotes) || !Array.isArray(record.facts)) {
7690
+ 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.`);
7691
+ return problems;
7692
+ }
7693
+ const facts = record.facts;
7694
+ const incomplete = facts.filter((f) => typeof f?.claim !== "string" || typeof f.source !== "string" || !f.source.trim() || ![true, false, "unchecked"].includes(f.holds));
7695
+ if (incomplete.length) {
7696
+ problems.push(`${recordPath}: ${incomplete.length} of its facts lack a \`claim\`, a \`source\` or a \`holds\` of true, false or "unchecked".`);
7697
+ }
7698
+ const wrong = facts.filter((f) => f?.holds === false);
7699
+ if (wrong.length) {
7700
+ problems.push(
7701
+ `${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.`
7702
+ );
7703
+ }
7704
+ const unsaid = facts.filter((f) => f?.holds === "unchecked" && !(typeof f.note === "string" && f.note.trim()));
7705
+ 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.`);
7706
+ 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) => {
7707
+ const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
7708
+ if (!file || !existsSync25(join41(root, file))) return true;
7709
+ if (!line) return false;
7710
+ try {
7711
+ return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
7712
+ } catch {
7713
+ return true;
7714
+ }
7715
+ });
7716
+ if (missing.length) {
7717
+ 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.`);
7718
+ }
7719
+ return problems;
7720
+ }
7721
+
7722
+ // src/commands/gate.ts
7232
7723
  var MAX_BLOCKS = 3;
7233
7724
  function lastJigInvocation(transcriptPath) {
7234
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7725
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7235
7726
  let text;
7236
7727
  try {
7237
- text = readFileSync32(transcriptPath, "utf8");
7728
+ text = readFileSync35(transcriptPath, "utf8");
7238
7729
  } catch {
7239
7730
  return void 0;
7240
7731
  }
@@ -7251,18 +7742,42 @@ function lastJigInvocation(transcriptPath) {
7251
7742
  const content = entry.message?.content;
7252
7743
  const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
7253
7744
  for (const t of texts) {
7254
- 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];
7745
+ 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];
7255
7746
  const [command, surface] = (args ?? "").trim().split(/\s+/);
7256
7747
  if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
7257
7748
  }
7258
7749
  }
7259
7750
  return found;
7260
7751
  }
7752
+ function ownerWords(transcriptPath) {
7753
+ if (!transcriptPath || !existsSync26(transcriptPath)) return "";
7754
+ const said = [];
7755
+ try {
7756
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7757
+ if (!line.includes('"user"')) continue;
7758
+ let entry;
7759
+ try {
7760
+ entry = JSON.parse(line);
7761
+ } catch {
7762
+ continue;
7763
+ }
7764
+ if (entry.type !== "user" || entry.isMeta) continue;
7765
+ const content = entry.message?.content;
7766
+ if (typeof content === "string") said.push(content);
7767
+ else if (Array.isArray(content)) {
7768
+ for (const b of content) if (b?.type === "text" && typeof b.text === "string") said.push(b.text);
7769
+ }
7770
+ }
7771
+ } catch {
7772
+ return "";
7773
+ }
7774
+ return said.join("\n");
7775
+ }
7261
7776
  function lastAssistantText(transcriptPath) {
7262
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7777
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7263
7778
  let found;
7264
7779
  try {
7265
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7780
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7266
7781
  if (!line.includes('"assistant"')) continue;
7267
7782
  let entry;
7268
7783
  try {
@@ -7286,9 +7801,9 @@ function asksOwner(text) {
7286
7801
  }
7287
7802
  var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
7288
7803
  function sessionStart(transcriptPath) {
7289
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7804
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7290
7805
  try {
7291
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7806
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7292
7807
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
7293
7808
  if (m) {
7294
7809
  const t = Date.parse(m[1]);
@@ -7303,20 +7818,20 @@ function verdictsMtime(dir) {
7303
7818
  let newest = 0;
7304
7819
  for (const name of VERDICT_FILES) {
7305
7820
  try {
7306
- newest = Math.max(newest, statSync5(join39(dir, name)).mtimeMs);
7821
+ newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
7307
7822
  } catch {
7308
7823
  }
7309
7824
  }
7310
7825
  return newest;
7311
7826
  }
7312
7827
  function surfacesInPlay(root, command, transcriptPath, surface) {
7313
- const critiqueDir = join39(root, ".jig", "critique");
7314
- if (!existsSync24(critiqueDir)) return [];
7315
- const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7828
+ const critiqueDir = join42(root, ".jig", "critique");
7829
+ if (!existsSync26(critiqueDir)) return [];
7830
+ const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7316
7831
  const start = sessionStart(transcriptPath);
7317
7832
  if (start === void 0) return all;
7318
7833
  const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
7319
- return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
7834
+ return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
7320
7835
  }
7321
7836
  function surfacesToProbe(root, input) {
7322
7837
  const invocation = lastJigInvocation(input.transcript_path);
@@ -7339,7 +7854,7 @@ function changedOutsideRecords(root, start) {
7339
7854
  const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7340
7855
  const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7341
7856
  try {
7342
- return start === void 0 || statSync5(join39(root, f)).mtimeMs >= start - 1e3;
7857
+ return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
7343
7858
  } catch {
7344
7859
  return false;
7345
7860
  }
@@ -7355,19 +7870,19 @@ function drawingChangedAfterApproval(root, specPath, at) {
7355
7870
  const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7356
7871
  if (!approvedIn) return void 0;
7357
7872
  const then = git(["show", `${approvedIn}:./${at}`]);
7358
- return then !== readFileSync32(join39(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7873
+ return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7359
7874
  } catch {
7360
7875
  return void 0;
7361
7876
  }
7362
7877
  }
7363
- function commandProblems(root, command, surface, start) {
7878
+ function commandProblems(root, command, surface, start, owner = "") {
7364
7879
  const problems = [];
7365
7880
  const spec = specFor(root, surface);
7366
7881
  if (command === "decide") {
7367
7882
  const found = decisionsFile(root);
7368
7883
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
7369
7884
  else {
7370
- const body = readFileSync32(join39(root, found), "utf8");
7885
+ const body = readFileSync35(join42(root, found), "utf8");
7371
7886
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
7372
7887
  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.`");
7373
7888
  }
@@ -7382,6 +7897,10 @@ function commandProblems(root, command, surface, start) {
7382
7897
  if (problems.length === 0) problems.push(...navProblems(spec));
7383
7898
  }
7384
7899
  }
7900
+ if (command === "spec" && spec) problems.push(...specChecked(root, spec, start, owner));
7901
+ if ((command === "spec" || command === "mockup" || command === "make") && spec) {
7902
+ problems.push(...mockupWordProblems(spec, fileAtSessionStart(root, spec.path, start), owner));
7903
+ }
7385
7904
  if (command === "mockup" && spec) {
7386
7905
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7387
7906
  const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
@@ -7389,12 +7908,12 @@ function commandProblems(root, command, surface, start) {
7389
7908
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7390
7909
  if (/^approved/i.test(mockup)) {
7391
7910
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
7392
- else if (!/^https?:/i.test(at) && !existsSync24(join39(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7911
+ else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7393
7912
  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.`);
7394
7913
  }
7395
- if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7914
+ if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7396
7915
  }
7397
- if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
7916
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
7398
7917
  if ((command === "spec" || command === "make") && spec) {
7399
7918
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7400
7919
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
@@ -7420,7 +7939,7 @@ function commandProblems(root, command, surface, start) {
7420
7939
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7421
7940
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7422
7941
  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))) {
7942
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7424
7943
  const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7425
7944
  if (changedAfter) {
7426
7945
  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.`);
@@ -7432,7 +7951,7 @@ function commandProblems(root, command, surface, start) {
7432
7951
  if (found) {
7433
7952
  let now = "";
7434
7953
  try {
7435
- now = readFileSync32(join39(root, found), "utf8");
7954
+ now = readFileSync35(join42(root, found), "utf8");
7436
7955
  } catch {
7437
7956
  }
7438
7957
  const then = fileAtSessionStart(root, found, start);
@@ -7446,13 +7965,13 @@ function commandProblems(root, command, surface, start) {
7446
7965
  if (found) {
7447
7966
  let now = "";
7448
7967
  try {
7449
- now = readFileSync32(join39(root, found), "utf8");
7968
+ now = readFileSync35(join42(root, found), "utf8");
7450
7969
  } catch {
7451
7970
  }
7452
7971
  const then = fileAtSessionStart(root, found, start);
7453
7972
  if (now && now !== then) {
7454
7973
  problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7455
- const change = readTweak(join39(root, ".jig", "critique", spec.slug))?.change;
7974
+ const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
7456
7975
  if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7457
7976
  }
7458
7977
  }
@@ -7461,7 +7980,7 @@ function commandProblems(root, command, surface, start) {
7461
7980
  for (const file of modeFiles(root)) {
7462
7981
  let now = "";
7463
7982
  try {
7464
- now = readFileSync32(join39(root, file), "utf8");
7983
+ now = readFileSync35(join42(root, file), "utf8");
7465
7984
  } catch {
7466
7985
  continue;
7467
7986
  }
@@ -7472,8 +7991,8 @@ function commandProblems(root, command, surface, start) {
7472
7991
  }
7473
7992
  }
7474
7993
  if (command === "critique") {
7475
- const dir = join39(root, ".jig", "critique");
7476
- const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
7994
+ const dir = join42(root, ".jig", "critique");
7995
+ const surfaces = existsSync26(dir) ? readdirSync16(dir).filter((s) => existsSync26(join42(dir, s, "screen.json")) || existsSync26(join42(dir, s, "code.json"))) : [];
7477
7996
  for (const surface2 of surfaces) {
7478
7997
  const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7479
7998
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -7484,7 +8003,7 @@ function commandProblems(root, command, surface, start) {
7484
8003
  }
7485
8004
  }
7486
8005
  const own = specFor(root, surface)?.slug;
7487
- if (own && surfaces.includes(own)) problems.push(...reportOmissions(join39(dir, own), own));
8006
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
7488
8007
  if (surfaces.length === 0) {
7489
8008
  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.");
7490
8009
  }
@@ -7497,13 +8016,22 @@ function drawingBeforeAsking(root, surface) {
7497
8016
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7498
8017
  const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7499
8018
  const at = named || `.jig/mockups/${spec.slug}.html`;
7500
- if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync24(join39(root, at))) return [];
8019
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
7501
8020
  const found = mockupDrawingProblems(root, spec.body, at);
7502
8021
  return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
7503
8022
  }
8023
+ function specChecked(root, spec, start, owner) {
8024
+ const found = decisionsFile(root);
8025
+ let decisions = "";
8026
+ try {
8027
+ if (found) decisions = readFileSync35(join42(root, found), "utf8");
8028
+ } catch {
8029
+ }
8030
+ return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
8031
+ }
7504
8032
  function modeFiles(root) {
7505
8033
  try {
7506
- const state = JSON.parse(readFileSync32(join39(root, ".jig", "state.json"), "utf8"));
8034
+ const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
7507
8035
  return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
7508
8036
  } catch {
7509
8037
  return [];
@@ -7512,7 +8040,7 @@ function modeFiles(root) {
7512
8040
  function reportOmissions(dir, surface) {
7513
8041
  let report2;
7514
8042
  try {
7515
- report2 = readFileSync32(join39(dir, "REPORT.md"), "utf8").toLowerCase();
8043
+ report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
7516
8044
  } catch {
7517
8045
  return [];
7518
8046
  }
@@ -7520,7 +8048,7 @@ function reportOmissions(dir, surface) {
7520
8048
  for (const f of VERDICT_FILES) {
7521
8049
  let file;
7522
8050
  try {
7523
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8051
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7524
8052
  } catch {
7525
8053
  continue;
7526
8054
  }
@@ -7533,7 +8061,7 @@ function reportOmissions(dir, surface) {
7533
8061
  if (!missing.length) return [];
7534
8062
  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
8063
  }
7536
- function tweakProblems(root, spec) {
8064
+ function tweakProblems(root, spec, owner = "") {
7537
8065
  const problems = [];
7538
8066
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7539
8067
  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\`.`);
@@ -7541,13 +8069,21 @@ function tweakProblems(root, spec) {
7541
8069
  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\`.`);
7542
8070
  else problems.push(...structureSinceApproval(root, spec, front));
7543
8071
  const surface = spec.slug;
7544
- const record = readTweak(join39(root, ".jig", "critique", surface));
8072
+ const record = readTweak(join42(root, ".jig", "critique", surface));
7545
8073
  if (!record) {
7546
8074
  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\`).`);
7547
8075
  } else {
7548
8076
  if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
7549
8077
  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.`);
7550
- const unjudged = unjudgedTweakIds(join39(root, ".jig", "critique", surface), record);
8078
+ if (record.deferred !== void 0) {
8079
+ if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
8080
+ problems.push(
8081
+ `.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.`
8082
+ );
8083
+ }
8084
+ return problems;
8085
+ }
8086
+ const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
7551
8087
  if (unjudged.length) {
7552
8088
  problems.push(
7553
8089
  `.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.`
@@ -7583,9 +8119,9 @@ function structureSinceApproval(root, spec, front) {
7583
8119
  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\`.`);
7584
8120
  }
7585
8121
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7586
- if (at && !/^https?:/i.test(at) && existsSync24(join39(root, at))) {
8122
+ if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7587
8123
  try {
7588
- if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
8124
+ if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
7589
8125
  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\`.`);
7590
8126
  }
7591
8127
  } catch {
@@ -7595,8 +8131,9 @@ function structureSinceApproval(root, spec, front) {
7595
8131
  }
7596
8132
  function readTweak(dir) {
7597
8133
  try {
7598
- const raw = JSON.parse(readFileSync32(join39(dir, "tweak.json"), "utf8"));
7599
- return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [] };
8134
+ const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
8135
+ const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
8136
+ return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
7600
8137
  } catch {
7601
8138
  return void 0;
7602
8139
  }
@@ -7607,20 +8144,37 @@ function surfacePage(projectRoot, surface) {
7607
8144
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7608
8145
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
7609
8146
  for (const candidate of candidates) {
7610
- if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
8147
+ if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
7611
8148
  }
7612
8149
  return void 0;
7613
8150
  }
7614
8151
  function gate(opts) {
7615
8152
  const root = opts.projectRoot;
7616
- if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
8153
+ if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
7617
8154
  return { block: false, reason: "" };
7618
8155
  }
7619
8156
  const invocation = lastJigInvocation(opts.input.transcript_path);
7620
8157
  const command = invocation?.command;
7621
8158
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7622
- const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, sessionStart(opts.input.transcript_path)).map((p) => `/jig ${command}: ${p}`) : [];
8159
+ const start = sessionStart(opts.input.transcript_path);
8160
+ const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
8161
+ const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
7623
8162
  if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
8163
+ if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
8164
+ const result = ship({ projectRoot: root, version: opts.version });
8165
+ 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.
8166
+ ${result.report}`);
8167
+ }
8168
+ if (command === "make") {
8169
+ const spec = specFor(root, invocation?.surface);
8170
+ if (spec && mockupPending(spec.body) && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
8171
+ 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.`);
8172
+ }
8173
+ }
8174
+ if (waiting && command === "spec") {
8175
+ const spec = specFor(root, invocation?.surface);
8176
+ if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
8177
+ }
7624
8178
  const selection = selectFiles(root, false);
7625
8179
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7626
8180
  if (changedUi) {
@@ -7642,11 +8196,11 @@ function gate(opts) {
7642
8196
  }
7643
8197
  }
7644
8198
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
7645
- const critiqueDir = join39(root, ".jig", "critique");
7646
- if (existsSync24(critiqueDir)) {
8199
+ const critiqueDir = join42(root, ".jig", "critique");
8200
+ if (existsSync26(critiqueDir)) {
7647
8201
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
7648
- const dir = join39(critiqueDir, surface);
7649
- if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
8202
+ const dir = join42(critiqueDir, surface);
8203
+ if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
7650
8204
  const v = verifyVerdicts({ projectRoot: root, surface });
7651
8205
  if (!v.ok) {
7652
8206
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -7668,11 +8222,11 @@ ${problems.join("\n\n")}`
7668
8222
  };
7669
8223
  }
7670
8224
  const session = opts.input.session_id ?? "unknown";
7671
- const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
7672
- const stateFile = join39(root, ".jig", "gate.json");
8225
+ const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
8226
+ const stateFile = join42(root, ".jig", "gate.json");
7673
8227
  let state = {};
7674
8228
  try {
7675
- state = JSON.parse(readFileSync32(stateFile, "utf8"));
8229
+ state = JSON.parse(readFileSync35(stateFile, "utf8"));
7676
8230
  } catch {
7677
8231
  }
7678
8232
  if (problems.length === 0) {
@@ -7707,7 +8261,7 @@ function verdictDigests(dir) {
7707
8261
  for (const f of VERDICT_FILES) {
7708
8262
  let file;
7709
8263
  try {
7710
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8264
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7711
8265
  } catch {
7712
8266
  continue;
7713
8267
  }
@@ -7718,9 +8272,16 @@ function verdictDigests(dir) {
7718
8272
  }
7719
8273
  return out;
7720
8274
  }
7721
- function writeLock(lockPath, dir, now) {
8275
+ function writeLock(root, surface, lockPath, dir, now) {
8276
+ const pageFile2 = recordedPage(root, surface);
8277
+ let page;
8278
+ try {
8279
+ if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
8280
+ } catch {
8281
+ }
8282
+ const tweakAt = readTweak(dir)?.at;
7722
8283
  try {
7723
- writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
8284
+ writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
7724
8285
  } catch {
7725
8286
  }
7726
8287
  }
@@ -7728,7 +8289,7 @@ function verdictChecksum(dir) {
7728
8289
  const parts = VERDICT_FILES.map((f) => {
7729
8290
  try {
7730
8291
  return `${f}
7731
- ${readFileSync32(join39(dir, f), "utf8")}`;
8292
+ ${readFileSync35(join42(dir, f), "utf8")}`;
7732
8293
  } catch {
7733
8294
  return "";
7734
8295
  }
@@ -7736,23 +8297,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
7736
8297
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
7737
8298
  }
7738
8299
  function verdictGuard(root, command, inPlay) {
7739
- const critiqueDir = join39(root, ".jig", "critique");
7740
- if (!existsSync24(critiqueDir)) return [];
8300
+ const critiqueDir = join42(root, ".jig", "critique");
8301
+ if (!existsSync26(critiqueDir)) return [];
7741
8302
  const problems = [];
7742
- for (const surface of readdirSync15(critiqueDir)) {
8303
+ for (const surface of readdirSync16(critiqueDir)) {
7743
8304
  if (surface.startsWith("_")) continue;
7744
- const dir = join39(critiqueDir, surface);
8305
+ const dir = join42(critiqueDir, surface);
7745
8306
  const now = verdictChecksum(dir);
7746
8307
  if (!now) continue;
7747
- const lockPath = join39(dir, LOCK);
7748
- if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7749
- writeLock(lockPath, dir, now);
8308
+ const lockPath = join42(dir, LOCK);
8309
+ if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
8310
+ writeLock(root, surface, lockPath, dir, now);
7750
8311
  problems.push(...lockLeftBehind(root, surface));
7751
8312
  continue;
7752
8313
  }
7753
8314
  let lock = {};
7754
8315
  try {
7755
- lock = JSON.parse(readFileSync32(lockPath, "utf8"));
8316
+ lock = JSON.parse(readFileSync35(lockPath, "utf8"));
7756
8317
  } catch {
7757
8318
  continue;
7758
8319
  }
@@ -7761,7 +8322,7 @@ function verdictGuard(root, command, inPlay) {
7761
8322
  const own = tweakVerdictProblems(dir, surface, lock.verdicts);
7762
8323
  problems.push(...own);
7763
8324
  if (own.length === 0) {
7764
- writeLock(lockPath, dir, now);
8325
+ writeLock(root, surface, lockPath, dir, now);
7765
8326
  problems.push(...lockLeftBehind(root, surface));
7766
8327
  }
7767
8328
  continue;
@@ -7809,7 +8370,7 @@ function unjudgedTweakIds(dir, record) {
7809
8370
  for (const f of VERDICT_FILES) {
7810
8371
  let file;
7811
8372
  try {
7812
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8373
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7813
8374
  } catch {
7814
8375
  continue;
7815
8376
  }
@@ -7821,112 +8382,15 @@ function unjudgedTweakIds(dir, record) {
7821
8382
  }
7822
8383
  function save(file, state) {
7823
8384
  try {
7824
- mkdirSync8(join39(file, ".."), { recursive: true });
8385
+ mkdirSync8(join42(file, ".."), { recursive: true });
7825
8386
  writeFileSync6(file, JSON.stringify(state), "utf8");
7826
8387
  } catch {
7827
8388
  }
7828
8389
  }
7829
8390
 
7830
- // src/commands/seo.ts
7831
- import { readFileSync as readFileSync33 } from "fs";
7832
- import { join as join40 } from "path";
7833
- var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7834
- var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7835
- var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
7836
- var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
7837
- var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
7838
- var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
7839
- var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
7840
- var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
7841
- function isPage(file, raw) {
7842
- if (TEST_FILE.test(file)) return false;
7843
- if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
7844
- const route = ROUTE_DIR.exec(file);
7845
- if (route) {
7846
- const rest = route[1];
7847
- const name = rest.split("/").pop();
7848
- if (name.startsWith("+")) return name === "+page.svelte";
7849
- if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
7850
- if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
7851
- if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
7852
- return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
7853
- }
7854
- return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
7855
- }
7856
- function routeOf(file) {
7857
- 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(/\/$/, "");
7858
- return `/${path}`.replace(/\/+/g, "/");
7859
- }
7860
- function seo(opts) {
7861
- const root = opts.projectRoot;
7862
- const files = selectFiles(root, true).files;
7863
- const findings = [];
7864
- const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
7865
- const pages = [];
7866
- let sitemapFiles = [];
7867
- let robotsFiles = [];
7868
- const sitemapRoutes = /* @__PURE__ */ new Map();
7869
- const pathLocs = [];
7870
- for (const file of files) {
7871
- let raw;
7872
- try {
7873
- raw = readFileSync33(join40(root, file), "utf8");
7874
- } catch {
7875
- continue;
7876
- }
7877
- if (SITEMAP_FILE.test(file)) {
7878
- sitemapFiles.push(file);
7879
- for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
7880
- const value = (match[1] ?? match[2] ?? "").trim();
7881
- if (!value) continue;
7882
- if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
7883
- const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
7884
- sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
7885
- }
7886
- continue;
7887
- }
7888
- if (ROBOTS_FILE.test(file)) {
7889
- robotsFiles.push(file);
7890
- continue;
7891
- }
7892
- if (!isPage(file, raw)) continue;
7893
- const found = readMetadata(raw);
7894
- const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
7895
- pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
7896
- }
7897
- for (const page of pages.filter((p) => p.noindex)) {
7898
- const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
7899
- if (listed) {
7900
- 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.`);
7901
- }
7902
- }
7903
- if (pathLocs.length > 0) {
7904
- const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
7905
- 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.`);
7906
- }
7907
- const byTitle = /* @__PURE__ */ new Map();
7908
- for (const page of pages) {
7909
- if (!page.title || page.noindex) continue;
7910
- byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
7911
- }
7912
- for (const [title, where] of byTitle) {
7913
- if (where.length > 1) {
7914
- 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.`);
7915
- }
7916
- }
7917
- const indexable = pages.filter((p) => !p.noindex);
7918
- const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
7919
- if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
7920
- 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.");
7921
- }
7922
- const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
7923
- 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}`;
7924
- return { findings, pages: pages.length, indexable: indexable.length, line };
7925
- }
7926
-
7927
8391
  // src/index.ts
7928
8392
  var packageRoot = getPackageRoot();
7929
- var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
8393
+ var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
7930
8394
  function warnIfUnpublishedPin() {
7931
8395
  if (isDevVersion(version)) {
7932
8396
  console.warn(
@@ -8107,6 +8571,17 @@ program.command("seo").description("Audit what a search engine and a link previe
8107
8571
  process.exit(1);
8108
8572
  }
8109
8573
  });
8574
+ 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(() => {
8575
+ const projectRoot = findProjectRoot(process.cwd());
8576
+ try {
8577
+ const result = ship({ projectRoot, version });
8578
+ console.log(result.report);
8579
+ process.exit(result.ready ? 0 : 1);
8580
+ } catch (err) {
8581
+ console.error(err.message);
8582
+ process.exit(1);
8583
+ }
8584
+ });
8110
8585
  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) => {
8111
8586
  if (opts.run) {
8112
8587
  if (!opts.save) {
@@ -8149,7 +8624,7 @@ program.command("probe").description("Print the render probe. With --save, read
8149
8624
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
8150
8625
  let input = {};
8151
8626
  try {
8152
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
8627
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
8153
8628
  } catch {
8154
8629
  }
8155
8630
  const cwd = input.cwd ?? process.cwd();
@@ -8189,7 +8664,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8189
8664
  });
8190
8665
  function resolveSurfaceMode(projectRoot) {
8191
8666
  try {
8192
- const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
8667
+ const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
8193
8668
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
8194
8669
  return modes.length === 1 ? modes[0] : void 0;
8195
8670
  } catch {