jig-ui 0.21.0 → 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";
@@ -5349,6 +5349,7 @@ async function init(opts) {
5349
5349
  log(`Proposed brand colour: hsl(${proposal.h} ${proposal.s}% ${proposal.l}%) \u2014 ${proposal.detail}`);
5350
5350
  for (const line of describeValidation(validation)) log(line);
5351
5351
  let surfaces = DEFAULT_SURFACES;
5352
+ let critique;
5352
5353
  if (!opts.yes) {
5353
5354
  for (let attempt = 0; attempt < 3; attempt++) {
5354
5355
  const answer = await prompt(
@@ -5376,6 +5377,12 @@ async function init(opts) {
5376
5377
  ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
5377
5378
  );
5378
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.`);
5379
5386
  }
5380
5387
  if (opts.yes) {
5381
5388
  const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
@@ -5483,7 +5490,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5483
5490
  }
5484
5491
  }
5485
5492
  if (configAction === "written") {
5486
- const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
5493
+ const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces, ...critique ? { critique } : {} };
5487
5494
  const content = `${JSON.stringify(config, null, 2)}
5488
5495
  `;
5489
5496
  writeFileSync4(configAbsPath, content, "utf8");
@@ -5499,6 +5506,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5499
5506
  const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5500
5507
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5501
5508
  const merged = {
5509
+ ...existing,
5510
+ ...existing.critique === void 0 && critique ? { critique } : {},
5502
5511
  brand: brandStillResolves ? existingBrand : brandRelPath,
5503
5512
  // Same contract as the 'written' branch: never invent a mapping under
5504
5513
  // `--yes`. An existing declaration is always kept.
@@ -6598,14 +6607,12 @@ function unsourcedReasons(current, before, opts = {}) {
6598
6607
  return problems;
6599
6608
  }
6600
6609
  function quotesNotFrom(current, before, words) {
6601
- const norm = (t) => t.toLowerCase().replace(/[‘’]/g, "'").replace(/\s+/g, " ").trim();
6602
- const said = norm(words);
6603
6610
  const problems = [];
6604
6611
  for (const [name, whys] of changedWhys(current, before, true)) {
6605
6612
  for (const why of whys) {
6606
6613
  const label = /^\s*\*\*(Why\b[^*]*):\*\*/.exec(why)[1];
6607
6614
  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)));
6615
+ const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
6609
6616
  if (missing.length) {
6610
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):**\`.`);
6611
6618
  break;
@@ -6614,6 +6621,11 @@ function quotesNotFrom(current, before, words) {
6614
6621
  }
6615
6622
  return problems;
6616
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
+ }
6617
6629
 
6618
6630
  // src/check/spec-shape.ts
6619
6631
  import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
@@ -6644,6 +6656,10 @@ function specProblems(spec) {
6644
6656
  if (specIndexableField(front) === "unreadable") {
6645
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.`);
6646
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
+ }
6647
6663
  for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6648
6664
  if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6649
6665
  }
@@ -7053,9 +7069,9 @@ function sourceChanges(git, from, to) {
7053
7069
  }
7054
7070
 
7055
7071
  // 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";
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";
7059
7075
 
7060
7076
  // src/check/mockup-drawing.ts
7061
7077
  import { readFileSync as readFileSync31 } from "fs";
@@ -7229,12 +7245,316 @@ function sizeBlock2(sizes, size) {
7229
7245
 
7230
7246
  // src/commands/gate.ts
7231
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
7232
7552
  var MAX_BLOCKS = 3;
7233
7553
  function lastJigInvocation(transcriptPath) {
7234
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7554
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7235
7555
  let text;
7236
7556
  try {
7237
- text = readFileSync32(transcriptPath, "utf8");
7557
+ text = readFileSync35(transcriptPath, "utf8");
7238
7558
  } catch {
7239
7559
  return void 0;
7240
7560
  }
@@ -7251,18 +7571,42 @@ function lastJigInvocation(transcriptPath) {
7251
7571
  const content = entry.message?.content;
7252
7572
  const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
7253
7573
  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];
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];
7255
7575
  const [command, surface] = (args ?? "").trim().split(/\s+/);
7256
7576
  if (command && /^[a-z]+$/i.test(command)) found = { command: command.toLowerCase(), ...surface && /^[\w.-]+$/.test(surface) ? { surface } : {} };
7257
7577
  }
7258
7578
  }
7259
7579
  return found;
7260
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
+ }
7261
7605
  function lastAssistantText(transcriptPath) {
7262
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7606
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7263
7607
  let found;
7264
7608
  try {
7265
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7609
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7266
7610
  if (!line.includes('"assistant"')) continue;
7267
7611
  let entry;
7268
7612
  try {
@@ -7286,9 +7630,9 @@ function asksOwner(text) {
7286
7630
  }
7287
7631
  var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
7288
7632
  function sessionStart(transcriptPath) {
7289
- if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
7633
+ if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7290
7634
  try {
7291
- for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
7635
+ for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
7292
7636
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
7293
7637
  if (m) {
7294
7638
  const t = Date.parse(m[1]);
@@ -7303,20 +7647,20 @@ function verdictsMtime(dir) {
7303
7647
  let newest = 0;
7304
7648
  for (const name of VERDICT_FILES) {
7305
7649
  try {
7306
- newest = Math.max(newest, statSync5(join39(dir, name)).mtimeMs);
7650
+ newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
7307
7651
  } catch {
7308
7652
  }
7309
7653
  }
7310
7654
  return newest;
7311
7655
  }
7312
7656
  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);
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);
7316
7660
  const start = sessionStart(transcriptPath);
7317
7661
  if (start === void 0) return all;
7318
7662
  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);
7663
+ return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
7320
7664
  }
7321
7665
  function surfacesToProbe(root, input) {
7322
7666
  const invocation = lastJigInvocation(input.transcript_path);
@@ -7339,7 +7683,7 @@ function changedOutsideRecords(root, start) {
7339
7683
  const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7340
7684
  const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7341
7685
  try {
7342
- return start === void 0 || statSync5(join39(root, f)).mtimeMs >= start - 1e3;
7686
+ return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
7343
7687
  } catch {
7344
7688
  return false;
7345
7689
  }
@@ -7355,19 +7699,19 @@ function drawingChangedAfterApproval(root, specPath, at) {
7355
7699
  const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7356
7700
  if (!approvedIn) return void 0;
7357
7701
  const then = git(["show", `${approvedIn}:./${at}`]);
7358
- return then !== readFileSync32(join39(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7702
+ return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
7359
7703
  } catch {
7360
7704
  return void 0;
7361
7705
  }
7362
7706
  }
7363
- function commandProblems(root, command, surface, start) {
7707
+ function commandProblems(root, command, surface, start, owner = "") {
7364
7708
  const problems = [];
7365
7709
  const spec = specFor(root, surface);
7366
7710
  if (command === "decide") {
7367
7711
  const found = decisionsFile(root);
7368
7712
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
7369
7713
  else {
7370
- const body = readFileSync32(join39(root, found), "utf8");
7714
+ const body = readFileSync35(join42(root, found), "utf8");
7371
7715
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
7372
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.`");
7373
7717
  }
@@ -7382,6 +7726,10 @@ function commandProblems(root, command, surface, start) {
7382
7726
  if (problems.length === 0) problems.push(...navProblems(spec));
7383
7727
  }
7384
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
+ }
7385
7733
  if (command === "mockup" && spec) {
7386
7734
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7387
7735
  const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
@@ -7389,12 +7737,12 @@ function commandProblems(root, command, surface, start) {
7389
7737
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7390
7738
  if (/^approved/i.test(mockup)) {
7391
7739
  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.`);
7740
+ else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7393
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.`);
7394
7742
  }
7395
- 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));
7396
7744
  }
7397
- if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec));
7745
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
7398
7746
  if ((command === "spec" || command === "make") && spec) {
7399
7747
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7400
7748
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
@@ -7420,7 +7768,7 @@ function commandProblems(root, command, surface, start) {
7420
7768
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7421
7769
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7422
7770
  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))) {
7771
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7424
7772
  const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7425
7773
  if (changedAfter) {
7426
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.`);
@@ -7432,7 +7780,7 @@ function commandProblems(root, command, surface, start) {
7432
7780
  if (found) {
7433
7781
  let now = "";
7434
7782
  try {
7435
- now = readFileSync32(join39(root, found), "utf8");
7783
+ now = readFileSync35(join42(root, found), "utf8");
7436
7784
  } catch {
7437
7785
  }
7438
7786
  const then = fileAtSessionStart(root, found, start);
@@ -7446,13 +7794,13 @@ function commandProblems(root, command, surface, start) {
7446
7794
  if (found) {
7447
7795
  let now = "";
7448
7796
  try {
7449
- now = readFileSync32(join39(root, found), "utf8");
7797
+ now = readFileSync35(join42(root, found), "utf8");
7450
7798
  } catch {
7451
7799
  }
7452
7800
  const then = fileAtSessionStart(root, found, start);
7453
7801
  if (now && now !== then) {
7454
7802
  problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7455
- const change = readTweak(join39(root, ".jig", "critique", spec.slug))?.change;
7803
+ const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
7456
7804
  if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7457
7805
  }
7458
7806
  }
@@ -7461,7 +7809,7 @@ function commandProblems(root, command, surface, start) {
7461
7809
  for (const file of modeFiles(root)) {
7462
7810
  let now = "";
7463
7811
  try {
7464
- now = readFileSync32(join39(root, file), "utf8");
7812
+ now = readFileSync35(join42(root, file), "utf8");
7465
7813
  } catch {
7466
7814
  continue;
7467
7815
  }
@@ -7472,8 +7820,8 @@ function commandProblems(root, command, surface, start) {
7472
7820
  }
7473
7821
  }
7474
7822
  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"))) : [];
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"))) : [];
7477
7825
  for (const surface2 of surfaces) {
7478
7826
  const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7479
7827
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -7484,7 +7832,7 @@ function commandProblems(root, command, surface, start) {
7484
7832
  }
7485
7833
  }
7486
7834
  const own = specFor(root, surface)?.slug;
7487
- if (own && surfaces.includes(own)) problems.push(...reportOmissions(join39(dir, own), own));
7835
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
7488
7836
  if (surfaces.length === 0) {
7489
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.");
7490
7838
  }
@@ -7497,13 +7845,22 @@ function drawingBeforeAsking(root, surface) {
7497
7845
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7498
7846
  const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7499
7847
  const at = named || `.jig/mockups/${spec.slug}.html`;
7500
- if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync24(join39(root, at))) return [];
7848
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
7501
7849
  const found = mockupDrawingProblems(root, spec.body, at);
7502
7850
  return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
7503
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
+ }
7504
7861
  function modeFiles(root) {
7505
7862
  try {
7506
- const state = JSON.parse(readFileSync32(join39(root, ".jig", "state.json"), "utf8"));
7863
+ const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
7507
7864
  return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
7508
7865
  } catch {
7509
7866
  return [];
@@ -7512,7 +7869,7 @@ function modeFiles(root) {
7512
7869
  function reportOmissions(dir, surface) {
7513
7870
  let report2;
7514
7871
  try {
7515
- report2 = readFileSync32(join39(dir, "REPORT.md"), "utf8").toLowerCase();
7872
+ report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
7516
7873
  } catch {
7517
7874
  return [];
7518
7875
  }
@@ -7520,7 +7877,7 @@ function reportOmissions(dir, surface) {
7520
7877
  for (const f of VERDICT_FILES) {
7521
7878
  let file;
7522
7879
  try {
7523
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
7880
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7524
7881
  } catch {
7525
7882
  continue;
7526
7883
  }
@@ -7533,7 +7890,7 @@ function reportOmissions(dir, surface) {
7533
7890
  if (!missing.length) return [];
7534
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).`];
7535
7892
  }
7536
- function tweakProblems(root, spec) {
7893
+ function tweakProblems(root, spec, owner = "") {
7537
7894
  const problems = [];
7538
7895
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7539
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\`.`);
@@ -7541,13 +7898,21 @@ function tweakProblems(root, spec) {
7541
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\`.`);
7542
7899
  else problems.push(...structureSinceApproval(root, spec, front));
7543
7900
  const surface = spec.slug;
7544
- const record = readTweak(join39(root, ".jig", "critique", surface));
7901
+ const record = readTweak(join42(root, ".jig", "critique", surface));
7545
7902
  if (!record) {
7546
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\`).`);
7547
7904
  } else {
7548
7905
  if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
7549
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.`);
7550
- 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);
7551
7916
  if (unjudged.length) {
7552
7917
  problems.push(
7553
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.`
@@ -7583,9 +7948,9 @@ function structureSinceApproval(root, spec, front) {
7583
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\`.`);
7584
7949
  }
7585
7950
  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))) {
7951
+ if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
7587
7952
  try {
7588
- if (git(["show", `${baseline}:./${at}`]) !== readFileSync32(join39(root, at), "utf8")) {
7953
+ if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
7589
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\`.`);
7590
7955
  }
7591
7956
  } catch {
@@ -7595,8 +7960,9 @@ function structureSinceApproval(root, spec, front) {
7595
7960
  }
7596
7961
  function readTweak(dir) {
7597
7962
  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) : [] };
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 } : {} };
7600
7966
  } catch {
7601
7967
  return void 0;
7602
7968
  }
@@ -7607,20 +7973,37 @@ function surfacePage(projectRoot, surface) {
7607
7973
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7608
7974
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
7609
7975
  for (const candidate of candidates) {
7610
- if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
7976
+ if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
7611
7977
  }
7612
7978
  return void 0;
7613
7979
  }
7614
7980
  function gate(opts) {
7615
7981
  const root = opts.projectRoot;
7616
- if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
7982
+ if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
7617
7983
  return { block: false, reason: "" };
7618
7984
  }
7619
7985
  const invocation = lastJigInvocation(opts.input.transcript_path);
7620
7986
  const command = invocation?.command;
7621
7987
  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}`) : [];
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}`) : [];
7623
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
+ }
7624
8007
  const selection = selectFiles(root, false);
7625
8008
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
7626
8009
  if (changedUi) {
@@ -7642,11 +8025,11 @@ function gate(opts) {
7642
8025
  }
7643
8026
  }
7644
8027
  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)) {
8028
+ const critiqueDir = join42(root, ".jig", "critique");
8029
+ if (existsSync26(critiqueDir)) {
7647
8030
  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;
8031
+ const dir = join42(critiqueDir, surface);
8032
+ if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
7650
8033
  const v = verifyVerdicts({ projectRoot: root, surface });
7651
8034
  if (!v.ok) {
7652
8035
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -7668,11 +8051,11 @@ ${problems.join("\n\n")}`
7668
8051
  };
7669
8052
  }
7670
8053
  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");
8054
+ const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
8055
+ const stateFile = join42(root, ".jig", "gate.json");
7673
8056
  let state = {};
7674
8057
  try {
7675
- state = JSON.parse(readFileSync32(stateFile, "utf8"));
8058
+ state = JSON.parse(readFileSync35(stateFile, "utf8"));
7676
8059
  } catch {
7677
8060
  }
7678
8061
  if (problems.length === 0) {
@@ -7707,7 +8090,7 @@ function verdictDigests(dir) {
7707
8090
  for (const f of VERDICT_FILES) {
7708
8091
  let file;
7709
8092
  try {
7710
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8093
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7711
8094
  } catch {
7712
8095
  continue;
7713
8096
  }
@@ -7718,9 +8101,16 @@ function verdictDigests(dir) {
7718
8101
  }
7719
8102
  return out;
7720
8103
  }
7721
- function writeLock(lockPath, dir, now) {
8104
+ function writeLock(root, surface, lockPath, dir, now) {
8105
+ const pageFile2 = recordedPage(root, surface);
8106
+ let page;
8107
+ try {
8108
+ if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
8109
+ } catch {
8110
+ }
8111
+ const tweakAt = readTweak(dir)?.at;
7722
8112
  try {
7723
- writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir) }) + "\n", "utf8");
8113
+ writeFileSync6(lockPath, JSON.stringify({ checksum: now, verdicts: verdictDigests(dir), ...page ? { page } : {}, ...tweakAt ? { tweak: tweakAt } : {} }) + "\n", "utf8");
7724
8114
  } catch {
7725
8115
  }
7726
8116
  }
@@ -7728,7 +8118,7 @@ function verdictChecksum(dir) {
7728
8118
  const parts = VERDICT_FILES.map((f) => {
7729
8119
  try {
7730
8120
  return `${f}
7731
- ${readFileSync32(join39(dir, f), "utf8")}`;
8121
+ ${readFileSync35(join42(dir, f), "utf8")}`;
7732
8122
  } catch {
7733
8123
  return "";
7734
8124
  }
@@ -7736,23 +8126,23 @@ ${readFileSync32(join39(dir, f), "utf8")}`;
7736
8126
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
7737
8127
  }
7738
8128
  function verdictGuard(root, command, inPlay) {
7739
- const critiqueDir = join39(root, ".jig", "critique");
7740
- if (!existsSync24(critiqueDir)) return [];
8129
+ const critiqueDir = join42(root, ".jig", "critique");
8130
+ if (!existsSync26(critiqueDir)) return [];
7741
8131
  const problems = [];
7742
- for (const surface of readdirSync15(critiqueDir)) {
8132
+ for (const surface of readdirSync16(critiqueDir)) {
7743
8133
  if (surface.startsWith("_")) continue;
7744
- const dir = join39(critiqueDir, surface);
8134
+ const dir = join42(critiqueDir, surface);
7745
8135
  const now = verdictChecksum(dir);
7746
8136
  if (!now) continue;
7747
- const lockPath = join39(dir, LOCK);
7748
- if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
7749
- 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);
7750
8140
  problems.push(...lockLeftBehind(root, surface));
7751
8141
  continue;
7752
8142
  }
7753
8143
  let lock = {};
7754
8144
  try {
7755
- lock = JSON.parse(readFileSync32(lockPath, "utf8"));
8145
+ lock = JSON.parse(readFileSync35(lockPath, "utf8"));
7756
8146
  } catch {
7757
8147
  continue;
7758
8148
  }
@@ -7761,7 +8151,7 @@ function verdictGuard(root, command, inPlay) {
7761
8151
  const own = tweakVerdictProblems(dir, surface, lock.verdicts);
7762
8152
  problems.push(...own);
7763
8153
  if (own.length === 0) {
7764
- writeLock(lockPath, dir, now);
8154
+ writeLock(root, surface, lockPath, dir, now);
7765
8155
  problems.push(...lockLeftBehind(root, surface));
7766
8156
  }
7767
8157
  continue;
@@ -7809,7 +8199,7 @@ function unjudgedTweakIds(dir, record) {
7809
8199
  for (const f of VERDICT_FILES) {
7810
8200
  let file;
7811
8201
  try {
7812
- file = JSON.parse(readFileSync32(join39(dir, f), "utf8"));
8202
+ file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
7813
8203
  } catch {
7814
8204
  continue;
7815
8205
  }
@@ -7821,112 +8211,15 @@ function unjudgedTweakIds(dir, record) {
7821
8211
  }
7822
8212
  function save(file, state) {
7823
8213
  try {
7824
- mkdirSync8(join39(file, ".."), { recursive: true });
8214
+ mkdirSync8(join42(file, ".."), { recursive: true });
7825
8215
  writeFileSync6(file, JSON.stringify(state), "utf8");
7826
8216
  } catch {
7827
8217
  }
7828
8218
  }
7829
8219
 
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
8220
  // src/index.ts
7928
8221
  var packageRoot = getPackageRoot();
7929
- var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
8222
+ var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
7930
8223
  function warnIfUnpublishedPin() {
7931
8224
  if (isDevVersion(version)) {
7932
8225
  console.warn(
@@ -8107,6 +8400,17 @@ program.command("seo").description("Audit what a search engine and a link previe
8107
8400
  process.exit(1);
8108
8401
  }
8109
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
+ });
8110
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) => {
8111
8415
  if (opts.run) {
8112
8416
  if (!opts.save) {
@@ -8149,7 +8453,7 @@ program.command("probe").description("Print the render probe. With --save, read
8149
8453
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
8150
8454
  let input = {};
8151
8455
  try {
8152
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
8456
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
8153
8457
  } catch {
8154
8458
  }
8155
8459
  const cwd = input.cwd ?? process.cwd();
@@ -8189,7 +8493,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8189
8493
  });
8190
8494
  function resolveSurfaceMode(projectRoot) {
8191
8495
  try {
8192
- const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
8496
+ const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
8193
8497
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
8194
8498
  return modes.length === 1 ? modes[0] : void 0;
8195
8499
  } catch {