jig-ui 0.23.0 → 0.25.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 readFileSync36 } from "fs";
6
- import { homedir as homedir2 } from "os";
7
- import { join as join43 } from "path";
5
+ import { existsSync as existsSync28, readFileSync as readFileSync37 } from "fs";
6
+ import { homedir as homedir3 } from "os";
7
+ import { join as join44 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -55,8 +55,8 @@ function cliInvocation(version2) {
55
55
  var JIG_GATE_HOOK = /(?:\bjig-ui@[^\s"]+|^jig) gate\b/;
56
56
 
57
57
  // src/commands/install.ts
58
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
59
- import { join as join7 } from "path";
58
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
59
+ import { join as join8 } from "path";
60
60
 
61
61
  // src/adapters/types.ts
62
62
  var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), make a small change to a built page (tweak), check the UI against the rules, or refresh the install.";
@@ -88,6 +88,236 @@ function agentsPointer(skillPath) {
88
88
  ].join("\n");
89
89
  }
90
90
 
91
+ // src/install/agent-files.ts
92
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
93
+ import { homedir } from "os";
94
+ import { join as join4 } from "path";
95
+
96
+ // src/text.ts
97
+ import { readFileSync } from "fs";
98
+ function lf(text) {
99
+ return text.replace(/\r\n/g, "\n");
100
+ }
101
+ function readText(path) {
102
+ return lf(readFileSync(path, "utf8"));
103
+ }
104
+
105
+ // src/install/atomic.ts
106
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
107
+ import { dirname as dirname2, join as join2 } from "path";
108
+ function writeFileAtomic(path, content) {
109
+ const dir = dirname2(path);
110
+ mkdirSync(dir, { recursive: true });
111
+ const tmp = join2(dir, `.${process.pid}-${Date.now()}.tmp`);
112
+ try {
113
+ writeFileSync(tmp, content, "utf8");
114
+ renameWithRetry(tmp, path);
115
+ } catch (err) {
116
+ try {
117
+ unlinkSync(tmp);
118
+ } catch {
119
+ }
120
+ throw err;
121
+ }
122
+ }
123
+ function renameWithRetry(from, to) {
124
+ const retryable = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
125
+ for (let waited = 0, delay = 10; ; waited += delay, delay = Math.min(delay * 2, 200)) {
126
+ try {
127
+ renameSync(from, to);
128
+ return;
129
+ } catch (err) {
130
+ const code = err.code ?? "";
131
+ if (process.platform !== "win32" || !retryable.has(code) || waited > 2e3) throw err;
132
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
133
+ }
134
+ }
135
+ }
136
+
137
+ // src/install/vendor.ts
138
+ import { posix } from "path";
139
+ var { join: join3 } = posix;
140
+ var DELIMITERS = {
141
+ html: ["<!--", "-->"],
142
+ css: ["/*", "*/"]
143
+ };
144
+ function licencePathFor(relPath) {
145
+ const depth = relPath.split("/").length - 1;
146
+ return depth === 0 ? "." : Array(depth).fill("..").join("/");
147
+ }
148
+ function vendorHeader(file, version2, style = "html", licencePath = ".") {
149
+ const [open, close] = DELIMITERS[style];
150
+ const attribution = licencePath === null ? " Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill." : ` Licensed Apache-2.0. See ${join3(licencePath, "LICENSE")} and ${join3(licencePath, "NOTICE")}.`;
151
+ return [
152
+ `${open} ${file} \u2014 vendored from Jig v${version2}.`,
153
+ attribution,
154
+ ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
155
+ "",
156
+ ""
157
+ ].join("\n");
158
+ }
159
+ function findMarkerRegions(existing) {
160
+ const regions = [];
161
+ let searchFrom = 0;
162
+ let openStart = null;
163
+ while (true) {
164
+ const nextStart = existing.indexOf(BLOCK_START, searchFrom);
165
+ const nextEnd = existing.indexOf(BLOCK_END, searchFrom);
166
+ if (nextStart === -1 && nextEnd === -1) break;
167
+ const endIsNext = nextEnd !== -1 && (nextStart === -1 || nextEnd < nextStart);
168
+ if (endIsNext) {
169
+ if (openStart !== null) {
170
+ regions.push({ start: openStart, end: nextEnd + BLOCK_END.length });
171
+ openStart = null;
172
+ } else {
173
+ regions.push({ start: nextEnd, end: nextEnd + BLOCK_END.length });
174
+ }
175
+ searchFrom = nextEnd + BLOCK_END.length;
176
+ } else {
177
+ if (openStart !== null) {
178
+ regions.push({ start: openStart, end: openStart + BLOCK_START.length });
179
+ }
180
+ openStart = nextStart;
181
+ searchFrom = nextStart + BLOCK_START.length;
182
+ }
183
+ }
184
+ if (openStart !== null) {
185
+ regions.push({ start: openStart, end: existing.length });
186
+ }
187
+ regions.sort((a, b) => a.start - b.start);
188
+ return regions;
189
+ }
190
+ function stripMarkerRegions(existing) {
191
+ const regions = findMarkerRegions(existing);
192
+ if (regions.length === 0) {
193
+ return { text: existing, insertAt: existing.length, removed: false };
194
+ }
195
+ let text = "";
196
+ let cursor = 0;
197
+ let insertAt = -1;
198
+ regions.forEach((region, i) => {
199
+ let chunk = existing.slice(cursor, region.start);
200
+ if (i > 0) chunk = chunk.replace(/^\n/, "");
201
+ text += chunk;
202
+ if (insertAt === -1) insertAt = text.length;
203
+ cursor = region.end;
204
+ });
205
+ const tail = existing.slice(cursor).replace(/^\n/, "");
206
+ text += tail;
207
+ return { text, insertAt, removed: true };
208
+ }
209
+ function upsertBlock(existing, block) {
210
+ const { text, insertAt, removed } = stripMarkerRegions(existing);
211
+ if (!removed) {
212
+ const separator = text.length && !text.endsWith("\n") ? "\n\n" : text.length ? "\n" : "";
213
+ return `${text}${separator}${block}`;
214
+ }
215
+ return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
216
+ }
217
+
218
+ // src/install/agent-files.ts
219
+ var AGENTS_FILE = "AGENTS.md";
220
+ var CLAUDE_FILE = "CLAUDE.md";
221
+ function agentsGuide() {
222
+ return [
223
+ BLOCK_START,
224
+ "",
225
+ "# Jig: the design system for this project's UI",
226
+ "",
227
+ "Before building or reviewing any interface, load the Jig skill and follow it:",
228
+ "`.agents/skills/jig/SKILL.md`, or `skills/jig/SKILL.md` in your agent's own",
229
+ "folder (`.claude/skills/jig/SKILL.md` for Claude Code). It says which rule",
230
+ "files to load for the task.",
231
+ "",
232
+ "## How work goes",
233
+ "",
234
+ "- `/jig decide`, once: the project's design decisions, from the owner.",
235
+ "- For each page or feature: `/jig spec`, then `/jig mockup` (or skip it), then",
236
+ " `/jig make`, then `/jig critique` (now, or left for `/jig ship`).",
237
+ "- A small change to a built page: `/jig tweak`.",
238
+ "- Before a release: `/jig ship`.",
239
+ "- After any change to UI files: `jig check`.",
240
+ "",
241
+ 'With no slash commands, ask in plain words: "run jig spec for the pricing page".',
242
+ "",
243
+ "## The owner's word",
244
+ "",
245
+ "A spec confirmed, a drawing approved or skipped, a critique left for later, a",
246
+ "decision's reason: each is recorded in the owner's own words. Never infer one",
247
+ "from something else they said; ask.",
248
+ "",
249
+ "## Jig's files",
250
+ "",
251
+ "- `jig.config.json`: the mode each route uses, files `check` skips (`exempt`),",
252
+ " and when pages are critiqued.",
253
+ "- The token folder: `brand.*.css` is the project's identity, edit it freely;",
254
+ " `mode.*.css` is Jig's, refreshed by `jig update`; `theme.css` imports both.",
255
+ "- `DECISIONS.md`, beside the tokens: the project's decisions, written by",
256
+ " `/jig decide` and `/jig tweak` only.",
257
+ "- `.jig/specs/`: what each page must be; `<name>.checked.json` is its check.",
258
+ "- `.jig/mockups/`: approved drawings.",
259
+ "- `.jig/critique/<page>/`: a critique's verdicts, its report and its lock.",
260
+ "- `.jig/state.json`: what Jig wrote, so `jig update` leaves your edits alone.",
261
+ "",
262
+ "Jig keeps this block current. Write your own instructions above or below it.",
263
+ "",
264
+ BLOCK_END,
265
+ ""
266
+ ].join("\n");
267
+ }
268
+ function claudeImport() {
269
+ return [
270
+ BLOCK_START,
271
+ "",
272
+ "Jig's instructions for this project are in AGENTS.md, imported here:",
273
+ "",
274
+ `@${AGENTS_FILE}`,
275
+ "",
276
+ BLOCK_END,
277
+ ""
278
+ ].join("\n");
279
+ }
280
+ function claudeInstalled(projectRoot, homeDir = homedir()) {
281
+ return existsSync2(join4(projectRoot, ".claude", "skills", "jig", "SKILL.md")) || existsSync2(join4(homeDir, ".claude", "skills", "jig", "SKILL.md"));
282
+ }
283
+ function blockIn(text) {
284
+ const t = lf(text);
285
+ const start = t.indexOf(BLOCK_START);
286
+ const end = t.indexOf(BLOCK_END, start);
287
+ return start >= 0 && end > start ? t.slice(start, end + BLOCK_END.length) : void 0;
288
+ }
289
+ var expected = (block) => block.trim();
290
+ function ensureAgentFiles(projectRoot, opts) {
291
+ const wanted = [[AGENTS_FILE, agentsGuide()]];
292
+ if (opts.claude) wanted.push([CLAUDE_FILE, claudeImport()]);
293
+ const written = [];
294
+ for (const [file, block] of wanted) {
295
+ const path = join4(projectRoot, file);
296
+ const before = existsSync2(path) ? readFileSync2(path, "utf8") : "";
297
+ if (blockIn(before) === expected(block)) continue;
298
+ writeFileAtomic(path, upsertBlock(before, block));
299
+ written.push(file);
300
+ }
301
+ return written;
302
+ }
303
+ function agentFileProblems(projectRoot, homeDir = homedir()) {
304
+ if (!existsSync2(join4(projectRoot, ".jig", "state.json"))) return [];
305
+ const wanted = [[AGENTS_FILE, agentsGuide()]];
306
+ if (claudeInstalled(projectRoot, homeDir)) wanted.push([CLAUDE_FILE, claudeImport()]);
307
+ const problems = [];
308
+ for (const [file, block] of wanted) {
309
+ const path = join4(projectRoot, file);
310
+ if (!existsSync2(path)) {
311
+ problems.push(`${file} is missing, so an agent starts without Jig's instructions.`);
312
+ continue;
313
+ }
314
+ const found = blockIn(readFileSync2(path, "utf8"));
315
+ if (!found) problems.push(`${file} has no Jig block, so an agent starts without Jig's instructions.`);
316
+ else if (found !== expected(block)) problems.push(`${file}: Jig's block has been edited or is out of date.`);
317
+ }
318
+ return problems.map((p) => `${p} Run \`jig update\` to put it back; your own text in the file stays as it is.`);
319
+ }
320
+
91
321
  // src/adapters/codex.ts
92
322
  var codex = {
93
323
  name: "codex",
@@ -96,7 +326,7 @@ var codex = {
96
326
  const skillPath = ".agents/skills/jig/SKILL.md";
97
327
  const files = [{ relPath: skillPath, content: ctx.skillBody }];
98
328
  const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
99
- files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
329
+ files.push({ relPath: agentsPath, content: ctx.scope === "global" ? agentsPointer(skillPath) : agentsGuide() });
100
330
  if (ctx.commandBody && ctx.scope === "global") {
101
331
  files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
102
332
  ` });
@@ -258,22 +488,22 @@ function skillFilesFor(adapter, ctx) {
258
488
  }
259
489
 
260
490
  // src/install/references.ts
261
- import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
262
- import { join as join2, posix } from "path";
491
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync } from "fs";
492
+ import { join as join5, posix as posix2 } from "path";
263
493
  function referenceFiles(packageRoot2) {
264
- const root = join2(packageRoot2, "references");
265
- if (!existsSync2(root)) return [];
494
+ const root = join5(packageRoot2, "references");
495
+ if (!existsSync3(root)) return [];
266
496
  const found = [];
267
497
  const walk2 = (dir, prefix) => {
268
498
  for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
269
499
  (a, b) => a.name.localeCompare(b.name)
270
500
  )) {
271
- const child = join2(dir, entry.name);
272
- const rel = prefix ? posix.join(prefix, entry.name) : entry.name;
501
+ const child = join5(dir, entry.name);
502
+ const rel = prefix ? posix2.join(prefix, entry.name) : entry.name;
273
503
  if (entry.isDirectory()) {
274
504
  walk2(child, rel);
275
505
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
276
- found.push({ relPath: rel, content: readFileSync(child, "utf8") });
506
+ found.push({ relPath: rel, content: readFileSync3(child, "utf8") });
277
507
  }
278
508
  }
279
509
  };
@@ -283,32 +513,11 @@ function referenceFiles(packageRoot2) {
283
513
 
284
514
  // src/install/manifest.ts
285
515
  import { createHash } from "crypto";
286
- import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
287
- import { dirname as dirname3, join as join4 } from "path";
288
-
289
- // src/install/atomic.ts
290
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
291
- import { dirname as dirname2, join as join3 } from "path";
292
- function writeFileAtomic(path, content) {
293
- const dir = dirname2(path);
294
- mkdirSync(dir, { recursive: true });
295
- const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
296
- try {
297
- writeFileSync(tmp, content, "utf8");
298
- renameSync(tmp, path);
299
- } catch (err) {
300
- try {
301
- unlinkSync(tmp);
302
- } catch {
303
- }
304
- throw err;
305
- }
306
- }
307
-
308
- // src/install/manifest.ts
516
+ import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
517
+ import { dirname as dirname3, join as join6 } from "path";
309
518
  var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
310
519
  function manifestPath(root, manifestDir) {
311
- return join4(root, ...manifestDir.split("/"), "manifest.json");
520
+ return join6(root, ...manifestDir.split("/"), "manifest.json");
312
521
  }
313
522
  function checksum(content) {
314
523
  const normalized = content.replace(/\r\n/g, "\n");
@@ -346,10 +555,10 @@ function validateManifest(raw, path) {
346
555
  }
347
556
  function readManifest(root, manifestDir = ".jig") {
348
557
  const path = manifestPath(root, manifestDir);
349
- if (!existsSync3(path)) return null;
558
+ if (!existsSync4(path)) return null;
350
559
  let raw;
351
560
  try {
352
- raw = JSON.parse(readFileSync2(path, "utf8"));
561
+ raw = JSON.parse(readFileSync4(path, "utf8"));
353
562
  } catch {
354
563
  throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
355
564
  }
@@ -379,12 +588,9 @@ function withLock(lock, fn) {
379
588
  }
380
589
  break;
381
590
  } catch (err) {
382
- if (err.code !== "EEXIST") throw err;
591
+ if (!BUSY.has(err.code ?? "")) throw err;
383
592
  if (lockIsStale(lock) || Date.now() > deadline) {
384
- try {
385
- unlinkSync2(lock);
386
- } catch {
387
- }
593
+ unlinkRetrying(lock);
388
594
  continue;
389
595
  }
390
596
  sleep(10 + Math.random() * 20);
@@ -393,15 +599,25 @@ function withLock(lock, fn) {
393
599
  try {
394
600
  fn();
395
601
  } finally {
602
+ unlinkRetrying(lock);
603
+ }
604
+ }
605
+ var BUSY = /* @__PURE__ */ new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
606
+ function unlinkRetrying(lock) {
607
+ for (let attempt = 0; attempt < 20; attempt++) {
396
608
  try {
397
609
  unlinkSync2(lock);
398
- } catch {
610
+ return;
611
+ } catch (err) {
612
+ const code = err.code ?? "";
613
+ if (code === "ENOENT" || !BUSY.has(code)) return;
614
+ sleep(5 + attempt * 5);
399
615
  }
400
616
  }
401
617
  }
402
618
  function lockIsStale(lock) {
403
619
  try {
404
- const [pid, at] = readFileSync2(lock, "utf8").trim().split(/\s+/).map(Number);
620
+ const [pid, at] = readFileSync4(lock, "utf8").trim().split(/\s+/).map(Number);
405
621
  if (at && Date.now() - at > LOCK_STALE_MS) return true;
406
622
  if (pid && pid !== process.pid) {
407
623
  try {
@@ -421,7 +637,7 @@ function sleep(ms) {
421
637
  }
422
638
  function readManifestQuietly(path) {
423
639
  try {
424
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
640
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
425
641
  if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
426
642
  return parsed;
427
643
  } catch {
@@ -431,9 +647,9 @@ function readManifestQuietly(path) {
431
647
  function isModified(projectRoot, relPath, m) {
432
648
  const recorded = m.files[relPath];
433
649
  if (!recorded) return false;
434
- const abs = join4(projectRoot, relPath);
435
- if (!existsSync3(abs)) return false;
436
- return checksum(readFileSync2(abs, "utf8")) !== recorded;
650
+ const abs = join6(projectRoot, relPath);
651
+ if (!existsSync4(abs)) return false;
652
+ return checksum(readFileSync4(abs, "utf8")) !== recorded;
437
653
  }
438
654
 
439
655
  // src/template/render.ts
@@ -460,97 +676,16 @@ function renderCommandTable(metadata2) {
460
676
  return ["| Command | Description | Status |", "| --- | --- | --- |", ...rows].join("\n");
461
677
  }
462
678
 
463
- // src/install/vendor.ts
464
- import { posix as posix2 } from "path";
465
- var { join: join5 } = posix2;
466
- var DELIMITERS = {
467
- html: ["<!--", "-->"],
468
- css: ["/*", "*/"]
469
- };
470
- function licencePathFor(relPath) {
471
- const depth = relPath.split("/").length - 1;
472
- return depth === 0 ? "." : Array(depth).fill("..").join("/");
473
- }
474
- function vendorHeader(file, version2, style = "html", licencePath = ".") {
475
- const [open, close] = DELIMITERS[style];
476
- const attribution = licencePath === null ? " Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill." : ` Licensed Apache-2.0. See ${join5(licencePath, "LICENSE")} and ${join5(licencePath, "NOTICE")}.`;
477
- return [
478
- `${open} ${file} \u2014 vendored from Jig v${version2}.`,
479
- attribution,
480
- ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
481
- "",
482
- ""
483
- ].join("\n");
484
- }
485
- function findMarkerRegions(existing) {
486
- const regions = [];
487
- let searchFrom = 0;
488
- let openStart = null;
489
- while (true) {
490
- const nextStart = existing.indexOf(BLOCK_START, searchFrom);
491
- const nextEnd = existing.indexOf(BLOCK_END, searchFrom);
492
- if (nextStart === -1 && nextEnd === -1) break;
493
- const endIsNext = nextEnd !== -1 && (nextStart === -1 || nextEnd < nextStart);
494
- if (endIsNext) {
495
- if (openStart !== null) {
496
- regions.push({ start: openStart, end: nextEnd + BLOCK_END.length });
497
- openStart = null;
498
- } else {
499
- regions.push({ start: nextEnd, end: nextEnd + BLOCK_END.length });
500
- }
501
- searchFrom = nextEnd + BLOCK_END.length;
502
- } else {
503
- if (openStart !== null) {
504
- regions.push({ start: openStart, end: openStart + BLOCK_START.length });
505
- }
506
- openStart = nextStart;
507
- searchFrom = nextStart + BLOCK_START.length;
508
- }
509
- }
510
- if (openStart !== null) {
511
- regions.push({ start: openStart, end: existing.length });
512
- }
513
- regions.sort((a, b) => a.start - b.start);
514
- return regions;
515
- }
516
- function stripMarkerRegions(existing) {
517
- const regions = findMarkerRegions(existing);
518
- if (regions.length === 0) {
519
- return { text: existing, insertAt: existing.length, removed: false };
520
- }
521
- let text = "";
522
- let cursor = 0;
523
- let insertAt = -1;
524
- regions.forEach((region, i) => {
525
- let chunk = existing.slice(cursor, region.start);
526
- if (i > 0) chunk = chunk.replace(/^\n/, "");
527
- text += chunk;
528
- if (insertAt === -1) insertAt = text.length;
529
- cursor = region.end;
530
- });
531
- const tail = existing.slice(cursor).replace(/^\n/, "");
532
- text += tail;
533
- return { text, insertAt, removed: true };
534
- }
535
- function upsertBlock(existing, block) {
536
- const { text, insertAt, removed } = stripMarkerRegions(existing);
537
- if (!removed) {
538
- const separator = text.length && !text.endsWith("\n") ? "\n\n" : text.length ? "\n" : "";
539
- return `${text}${separator}${block}`;
540
- }
541
- return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
542
- }
543
-
544
679
  // src/install/writer.ts
545
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
546
- import { dirname as dirname4, join as join6 } from "path";
680
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
681
+ import { dirname as dirname4, join as join7 } from "path";
547
682
 
548
683
  // src/install/line-endings.ts
549
684
  function dominantEnding(existing) {
550
685
  const crlf = (existing.match(/\r\n/g) ?? []).length;
551
- const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
552
- if (crlf === 0 && lf === 0) return "\n";
553
- return crlf >= lf ? "\r\n" : "\n";
686
+ const lf5 = (existing.match(/(?<!\r)\n/g) ?? []).length;
687
+ if (crlf === 0 && lf5 === 0) return "\n";
688
+ return crlf >= lf5 ? "\r\n" : "\n";
554
689
  }
555
690
  function matchLineEndings(content, existing) {
556
691
  const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -567,9 +702,9 @@ function createWriter(root, seed = {}) {
567
702
  return {
568
703
  files,
569
704
  write(key, content) {
570
- const abs = join6(root, ...key.split("/"));
705
+ const abs = join7(root, ...key.split("/"));
571
706
  mkdirSync3(dirname4(abs), { recursive: true });
572
- const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
707
+ const existing = existsSync5(abs) ? readFileSync5(abs, "utf8") : void 0;
573
708
  const toWrite = matchLineEndings(content, existing);
574
709
  writeFileAtomic(abs, toWrite);
575
710
  files[key] = checksum(toWrite);
@@ -579,15 +714,15 @@ function createWriter(root, seed = {}) {
579
714
  }
580
715
  function bundleFiles(packageRoot2, dir) {
581
716
  const ext = dir === "rules" ? ".md" : ".css";
582
- return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
717
+ return readdirSync2(join7(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
583
718
  }
584
719
 
585
720
  // src/commands/install.ts
586
721
  var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
587
722
  function buildSkillBody(packageRoot2, rulesPath, version2) {
588
- const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
723
+ const template = readFileSync6(join8(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
589
724
  const metadata2 = JSON.parse(
590
- readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
725
+ readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
591
726
  );
592
727
  return render(template, {
593
728
  command_prefix: "/jig ",
@@ -604,12 +739,12 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
604
739
  }
605
740
  function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
606
741
  const metadata2 = JSON.parse(
607
- readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
742
+ readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
608
743
  );
609
744
  const subcommands = Object.entries(metadata2).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
610
- const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
611
- if (!existsSync5(templatePath)) return { body: "", subcommands };
612
- const template = readFileSync4(templatePath, "utf8");
745
+ const templatePath = join8(packageRoot2, "templates", "COMMAND.md.tmpl");
746
+ if (!existsSync6(templatePath)) return { body: "", subcommands };
747
+ const template = readFileSync6(templatePath, "utf8");
613
748
  const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, cliInvocation(version2)).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath).replace(/\{\{config_file\}\}/g, "jig.config.json");
614
749
  const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
615
750
  if (unresolved.length > 0) {
@@ -637,6 +772,7 @@ function install(opts) {
637
772
  written: [],
638
773
  skipped: [],
639
774
  stopHook: installStopHook(opts.projectRoot, opts.version),
775
+ agentFiles: ensureAgentFiles(opts.projectRoot, { claude: true }),
640
776
  hookOnly: `Jig's skill is installed globally for 'claude' (${globalReferenceDir} under your home directory), so it stays there. Added only this project's Stop hook.`
641
777
  };
642
778
  }
@@ -657,9 +793,9 @@ function install(opts) {
657
793
  existingManifest = null;
658
794
  }
659
795
  const planned = [];
660
- const rulesDir = join7(opts.packageRoot, "rules");
796
+ const rulesDir = join8(opts.packageRoot, "rules");
661
797
  for (const file of bundleFiles(opts.packageRoot, "rules")) {
662
- const body = readFileSync4(join7(rulesDir, file), "utf8");
798
+ const body = readFileSync6(join8(rulesDir, file), "utf8");
663
799
  planned.push({
664
800
  key: relKey(referenceDir, "rules", file),
665
801
  content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
@@ -675,13 +811,13 @@ function install(opts) {
675
811
  }
676
812
  planned.push({
677
813
  key: relKey(referenceDir, "rules.index.json"),
678
- content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
814
+ content: readFileSync6(join8(opts.packageRoot, "rules.index.json"), "utf8"),
679
815
  checkable: true
680
816
  });
681
817
  for (const src of ["LICENSE", "NOTICE"]) {
682
818
  planned.push({
683
819
  key: relKey(referenceDir, src),
684
- content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
820
+ content: readFileSync6(join8(opts.packageRoot, src), "utf8"),
685
821
  checkable: false
686
822
  });
687
823
  }
@@ -703,9 +839,9 @@ function install(opts) {
703
839
  });
704
840
  for (const file of skillFiles) {
705
841
  const key = file.relPath;
706
- const abs = join7(installRoot, ...key.split("/"));
842
+ const abs = join8(installRoot, ...key.split("/"));
707
843
  const isBlockFile = file.content.includes(BLOCK_START);
708
- const content = isBlockFile && existsSync5(abs) ? upsertBlock(readFileSync4(abs, "utf8"), file.content) : file.content;
844
+ const content = isBlockFile && existsSync6(abs) ? upsertBlock(readFileSync6(abs, "utf8"), file.content) : file.content;
709
845
  planned.push({ key, content, checkable: !isBlockFile });
710
846
  }
711
847
  const writer = createWriter(installRoot);
@@ -732,11 +868,12 @@ function install(opts) {
732
868
  writeManifest(installRoot, manifest, referenceDir);
733
869
  written.push(relKey(referenceDir, "manifest.json"));
734
870
  const stopHook = opts.hook && opts.agent === "claude" && opts.scope === "project" ? installStopHook(installRoot, opts.version) : void 0;
735
- return { written, skipped, stopHook };
871
+ const agentFiles = opts.scope === "project" ? ensureAgentFiles(opts.projectRoot, { claude: opts.agent === "claude" || claudeInstalled(opts.projectRoot, opts.homeDir) }) : [];
872
+ return { written, skipped, stopHook, agentFiles };
736
873
  }
737
874
  function hasStopHook(projectRoot) {
738
875
  try {
739
- const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
876
+ const settings = readFileSync6(join8(projectRoot, ".claude", "settings.json"), "utf8");
740
877
  const hooks = JSON.parse(settings).hooks?.Stop ?? [];
741
878
  return hooks.some((group) => (group.hooks ?? []).some((h) => JIG_GATE_HOOK.test(h.command ?? "")));
742
879
  } catch {
@@ -744,11 +881,11 @@ function hasStopHook(projectRoot) {
744
881
  }
745
882
  }
746
883
  function installStopHook(projectRoot, version2) {
747
- const path = join7(projectRoot, ".claude", "settings.json");
884
+ const path = join8(projectRoot, ".claude", "settings.json");
748
885
  let settings = {};
749
- if (existsSync5(path)) {
886
+ if (existsSync6(path)) {
750
887
  try {
751
- settings = JSON.parse(readFileSync4(path, "utf8"));
888
+ settings = JSON.parse(readFileSync6(path, "utf8"));
752
889
  } catch {
753
890
  return false;
754
891
  }
@@ -759,14 +896,14 @@ function installStopHook(projectRoot, version2) {
759
896
  const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
760
897
  stop.push({ hooks: [{ type: "command", command }] });
761
898
  settings.hooks = { ...hooks, Stop: stop };
762
- mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
899
+ mkdirSync4(join8(projectRoot, ".claude"), { recursive: true });
763
900
  writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
764
901
  return true;
765
902
  }
766
903
 
767
904
  // src/commands/update.ts
768
- import { join as join10 } from "path";
769
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
905
+ import { join as join11 } from "path";
906
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
770
907
 
771
908
  // src/install/target.ts
772
909
  import { resolve } from "path";
@@ -809,14 +946,14 @@ function resolveAllInstalled(projectRoot, homeDir) {
809
946
  }
810
947
 
811
948
  // src/init/state.ts
812
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5 } from "fs";
813
- import { dirname as dirname5, join as join8 } from "path";
814
- var INIT_MANIFEST_REL = join8(".jig", "state.json");
949
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync7 } from "fs";
950
+ import { dirname as dirname5, join as join9 } from "path";
951
+ var INIT_MANIFEST_REL = join9(".jig", "state.json");
815
952
  function readInitManifest(projectRoot) {
816
- const path = join8(projectRoot, INIT_MANIFEST_REL);
817
- if (!existsSync6(path)) return null;
953
+ const path = join9(projectRoot, INIT_MANIFEST_REL);
954
+ if (!existsSync7(path)) return null;
818
955
  try {
819
- const raw = JSON.parse(readFileSync5(path, "utf8"));
956
+ const raw = JSON.parse(readFileSync7(path, "utf8"));
820
957
  if (typeof raw !== "object" || raw === null) return null;
821
958
  const { files, version: version2, modes } = raw;
822
959
  if (typeof files !== "object" || files === null) return null;
@@ -831,7 +968,7 @@ function readInitManifest(projectRoot) {
831
968
  }
832
969
  var MERGE_ATTEMPTS = 5;
833
970
  function writeInitManifest(projectRoot, m) {
834
- const path = join8(projectRoot, INIT_MANIFEST_REL);
971
+ const path = join9(projectRoot, INIT_MANIFEST_REL);
835
972
  mkdirSync5(dirname5(path), { recursive: true });
836
973
  for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
837
974
  const onDisk = readInitManifest(projectRoot);
@@ -852,15 +989,15 @@ function isInitFileModified(projectRoot, relPath, m) {
852
989
  }
853
990
 
854
991
  // src/init/migrate.ts
855
- import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
856
- import { join as join9 } from "path";
992
+ import { existsSync as existsSync8, readFileSync as readFileSync8, readdirSync as readdirSync3, rmSync } from "fs";
993
+ import { join as join10 } from "path";
857
994
  var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
858
995
  function isLegacyInstallArtifact(name) {
859
996
  return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
860
997
  }
861
998
  function readLegacyManifest(dirAbs) {
862
999
  try {
863
- const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
1000
+ const raw = JSON.parse(readFileSync8(join10(dirAbs, "manifest.json"), "utf8"));
864
1001
  if (raw && typeof raw === "object" && typeof raw.files === "object") {
865
1002
  return raw;
866
1003
  }
@@ -872,8 +1009,8 @@ function classify(projectRoot, relPath, legacyManifest) {
872
1009
  return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
873
1010
  }
874
1011
  function detectLegacyRules(projectRoot) {
875
- const jigDir = join9(projectRoot, ".jig");
876
- if (!existsSync7(jigDir)) return { present: false, files: [] };
1012
+ const jigDir = join10(projectRoot, ".jig");
1013
+ if (!existsSync8(jigDir)) return { present: false, files: [] };
877
1014
  const legacyManifest = readLegacyManifest(jigDir);
878
1015
  let entries;
879
1016
  try {
@@ -889,12 +1026,12 @@ function detectLegacyRules(projectRoot) {
889
1026
  }
890
1027
  function detectLegacyCursorRules(projectRoot) {
891
1028
  const bundleDirRel = ".cursor/rules/jig";
892
- const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
1029
+ const bundleDir = join10(projectRoot, ...bundleDirRel.split("/"));
893
1030
  const mdcRel = ".cursor/rules/jig.mdc";
894
- const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
1031
+ const mdcAbs = join10(projectRoot, ...mdcRel.split("/"));
895
1032
  const legacyManifest = readLegacyManifest(bundleDir);
896
1033
  const files = [];
897
- if (existsSync7(mdcAbs)) {
1034
+ if (existsSync8(mdcAbs)) {
898
1035
  files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
899
1036
  }
900
1037
  let entries;
@@ -948,7 +1085,7 @@ function removableLegacyFiles(report2) {
948
1085
  function removeLegacyFiles(projectRoot, relPaths) {
949
1086
  for (const relPath of relPaths) {
950
1087
  try {
951
- rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
1088
+ rmSync(join10(projectRoot, ...relPath.split("/")), { force: true });
952
1089
  } catch {
953
1090
  }
954
1091
  }
@@ -991,6 +1128,9 @@ function update(opts) {
991
1128
  const initResult = updateInitFiles(opts);
992
1129
  updated.push(...initResult.updated);
993
1130
  skipped.push(...initResult.skipped);
1131
+ if (existsSync9(join11(opts.projectRoot, ".jig", "state.json"))) {
1132
+ updated.push(...ensureAgentFiles(opts.projectRoot, { claude: claudeInstalled(opts.projectRoot, opts.homeDir) }));
1133
+ }
994
1134
  return {
995
1135
  updated,
996
1136
  skipped,
@@ -1013,14 +1153,14 @@ function updateTarget(opts, resolved) {
1013
1153
  const write = (key, content) => {
1014
1154
  updated.push(writer.write(key, content));
1015
1155
  };
1016
- const rulesDir = join10(opts.packageRoot, "rules");
1156
+ const rulesDir = join11(opts.packageRoot, "rules");
1017
1157
  for (const file of bundleFiles(opts.packageRoot, "rules")) {
1018
1158
  const key = relKey(referenceDir, "rules", file);
1019
1159
  if (isModified(installRoot, key, existing)) {
1020
1160
  skipped.push(key);
1021
1161
  continue;
1022
1162
  }
1023
- write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync7(join10(rulesDir, file), "utf8"));
1163
+ write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync9(join11(rulesDir, file), "utf8"));
1024
1164
  }
1025
1165
  for (const ref of referenceFiles(opts.packageRoot)) {
1026
1166
  const key = relKey(referenceDir, ...ref.relPath.split("/"));
@@ -1034,7 +1174,7 @@ function updateTarget(opts, resolved) {
1034
1174
  if (isModified(installRoot, indexKey, existing)) {
1035
1175
  skipped.push(indexKey);
1036
1176
  } else {
1037
- write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
1177
+ write(indexKey, readFileSync9(join11(opts.packageRoot, "rules.index.json"), "utf8"));
1038
1178
  }
1039
1179
  const rulesPath = rulesPathFor(referenceDir, discoveredScope);
1040
1180
  const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
@@ -1056,8 +1196,8 @@ function updateTarget(opts, resolved) {
1056
1196
  const key = file.relPath;
1057
1197
  const isBlockFile = file.content.includes(BLOCK_START);
1058
1198
  if (isBlockFile) {
1059
- const abs = join10(installRoot, ...key.split("/"));
1060
- const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
1199
+ const abs = join11(installRoot, ...key.split("/"));
1200
+ const current = existsSync9(abs) ? readFileSync9(abs, "utf8") : "";
1061
1201
  write(key, upsertBlock(current, file.content));
1062
1202
  continue;
1063
1203
  }
@@ -1068,7 +1208,7 @@ function updateTarget(opts, resolved) {
1068
1208
  write(key, file.content);
1069
1209
  }
1070
1210
  for (const src of ["LICENSE", "NOTICE"]) {
1071
- write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
1211
+ write(relKey(referenceDir, src), readFileSync9(join11(opts.packageRoot, src), "utf8"));
1072
1212
  }
1073
1213
  const manifest = {
1074
1214
  ...existing,
@@ -1084,7 +1224,7 @@ function updateInitFiles(opts) {
1084
1224
  const updated = [];
1085
1225
  const skipped = [];
1086
1226
  const tokens = [];
1087
- const tokensDir = join10(opts.packageRoot, "tokens");
1227
+ const tokensDir = join11(opts.packageRoot, "tokens");
1088
1228
  const initManifest = readInitManifest(opts.projectRoot);
1089
1229
  if (initManifest) {
1090
1230
  const initFiles = { ...initManifest.files };
@@ -1096,10 +1236,10 @@ function updateInitFiles(opts) {
1096
1236
  for (const key of keys) {
1097
1237
  let before = "";
1098
1238
  try {
1099
- before = readFileSync7(join10(opts.projectRoot, ...key.split("/")), "utf8");
1239
+ before = readFileSync9(join11(opts.projectRoot, ...key.split("/")), "utf8");
1100
1240
  } catch {
1101
1241
  }
1102
- const fresh = readFileSync7(join10(tokensDir, file), "utf8");
1242
+ const fresh = readFileSync9(join11(tokensDir, file), "utf8");
1103
1243
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
1104
1244
  skipped.push(key);
1105
1245
  const change2 = tokenChange(key, before, fresh, true);
@@ -1120,12 +1260,12 @@ function updateInitFiles(opts) {
1120
1260
  }
1121
1261
 
1122
1262
  // src/commands/explain.ts
1123
- import { existsSync as existsSync9 } from "fs";
1124
- import { join as join15 } from "path";
1263
+ import { existsSync as existsSync10 } from "fs";
1264
+ import { join as join16 } from "path";
1125
1265
 
1126
1266
  // src/rules/load.ts
1127
- import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
1128
- import { join as join11 } from "path";
1267
+ import { readFileSync as readFileSync10, readdirSync as readdirSync4 } from "fs";
1268
+ import { join as join12 } from "path";
1129
1269
 
1130
1270
  // src/rules/parse.ts
1131
1271
  var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
@@ -1221,9 +1361,9 @@ function loadRules(rulesDir, indexPath) {
1221
1361
  const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
1222
1362
  const parsed = [];
1223
1363
  for (const file of files) {
1224
- parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
1364
+ parsed.push(...parseRules(readFileSync10(join12(rulesDir, file), "utf8"), file));
1225
1365
  }
1226
- const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
1366
+ const entries = validateIndex(JSON.parse(readFileSync10(indexPath, "utf8")));
1227
1367
  const byId = new Map(entries.map((e) => [e.id, e]));
1228
1368
  const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1229
1369
  if (missing.length) {
@@ -1245,8 +1385,8 @@ function loadRules(rulesDir, indexPath) {
1245
1385
  }
1246
1386
 
1247
1387
  // src/rules/specs.ts
1248
- import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
1249
- import { join as join12 } from "path";
1388
+ import { readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
1389
+ import { join as join13 } from "path";
1250
1390
  var KINDS = {
1251
1391
  P: "pattern",
1252
1392
  M: "mode",
@@ -1305,14 +1445,14 @@ function parseSpecs(markdown, sourceFile) {
1305
1445
  function loadSpecs(rulesDir) {
1306
1446
  const specs = [];
1307
1447
  for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1308
- specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
1448
+ specs.push(...parseSpecs(readFileSync11(join13(rulesDir, file), "utf8"), file));
1309
1449
  }
1310
1450
  return specs;
1311
1451
  }
1312
1452
 
1313
1453
  // src/rules/sections.ts
1314
- import { readFileSync as readFileSync10, readdirSync as readdirSync6 } from "fs";
1315
- import { join as join13 } from "path";
1454
+ import { readFileSync as readFileSync12, readdirSync as readdirSync6 } from "fs";
1455
+ import { join as join14 } from "path";
1316
1456
  var RULE_HEADING = /^###\s+[A-Z]-\d+\b/;
1317
1457
  var SPEC_HEADING = /^##\s+[PMLRT]-\d+\b/;
1318
1458
  var ANY_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
@@ -1353,16 +1493,16 @@ function parseSections(markdown, file) {
1353
1493
  function loadSections(rulesDir) {
1354
1494
  const out = [];
1355
1495
  for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1356
- out.push(...parseSections(readFileSync10(join13(rulesDir, file), "utf8"), file));
1496
+ out.push(...parseSections(readFileSync12(join14(rulesDir, file), "utf8"), file));
1357
1497
  }
1358
1498
  return out;
1359
1499
  }
1360
1500
 
1361
1501
  // src/rules/layers.ts
1362
- import { readFileSync as readFileSync11 } from "fs";
1363
- import { join as join14 } from "path";
1502
+ import { readFileSync as readFileSync13 } from "fs";
1503
+ import { join as join15 } from "path";
1364
1504
  function loadLayers(packageRoot2) {
1365
- return JSON.parse(readFileSync11(join14(packageRoot2, "layers.json"), "utf8"));
1505
+ return JSON.parse(readFileSync13(join15(packageRoot2, "layers.json"), "utf8"));
1366
1506
  }
1367
1507
 
1368
1508
  // src/commands/explain.ts
@@ -1444,8 +1584,8 @@ function renderSection(s) {
1444
1584
  function explain(opts) {
1445
1585
  const raw = opts.ruleId.trim().toUpperCase();
1446
1586
  const root = opts.packageRoot ?? assetRoot();
1447
- const rulesDir = join15(root, "rules");
1448
- const rules = loadRules(rulesDir, join15(root, "rules.index.json"));
1587
+ const rulesDir = join16(root, "rules");
1588
+ const rules = loadRules(rulesDir, join16(root, "rules.index.json"));
1449
1589
  const specs = loadSpecs(rulesDir);
1450
1590
  const sections = loadSections(rulesDir);
1451
1591
  const entries = searchEntries(rules, specs, sections);
@@ -1501,8 +1641,8 @@ function explain(opts) {
1501
1641
  const id = `${shape[1]}-${shape[2]}`;
1502
1642
  const rule = rules.find((r) => r.id === id);
1503
1643
  if (rule) {
1504
- const example = join15(root, "examples", `${id}.html`);
1505
- return existsSync9(example) ? `${renderRule(rule)}
1644
+ const example = join16(root, "examples", `${id}.html`);
1645
+ return existsSync10(example) ? `${renderRule(rule)}
1506
1646
 
1507
1647
  Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : renderRule(rule);
1508
1648
  }
@@ -1559,13 +1699,13 @@ function sectionsOf(entries) {
1559
1699
  }
1560
1700
 
1561
1701
  // src/commands/check.ts
1562
- import { existsSync as existsSync15, readFileSync as readFileSync19 } from "fs";
1563
- import { join as join24 } from "path";
1702
+ import { existsSync as existsSync16, readFileSync as readFileSync21 } from "fs";
1703
+ import { join as join25 } from "path";
1564
1704
 
1565
1705
  // src/check/files.ts
1566
1706
  import { execFileSync } from "child_process";
1567
1707
  import { readdirSync as readdirSync7, realpathSync, statSync as statSync2 } from "fs";
1568
- import { join as join16, relative, sep } from "path";
1708
+ import { join as join17, relative, sep } from "path";
1569
1709
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1570
1710
  "node_modules",
1571
1711
  "dist",
@@ -1640,7 +1780,7 @@ function walk(root, dir, out, seen) {
1640
1780
  }
1641
1781
  for (const entry of entries) {
1642
1782
  if (EXCLUDE_DIRS.has(entry.name)) continue;
1643
- const abs = join16(dir, entry.name);
1783
+ const abs = join17(dir, entry.name);
1644
1784
  let isDir = entry.isDirectory();
1645
1785
  let isFile = entry.isFile();
1646
1786
  if (entry.isSymbolicLink()) {
@@ -1782,6 +1922,10 @@ function formatReport(findings, meta) {
1782
1922
  ` Scope: files changed since HEAD, not the whole project \u2014 this is the inner-loop default. A clean result here means nothing in your diff fired, not that the project is clean. Run 'jig check --all' for that.`
1783
1923
  );
1784
1924
  }
1925
+ for (const problem of meta.agentFiles ?? []) lines.push(` ${problem}`);
1926
+ for (const { entry, why } of meta.exemptIgnored ?? []) {
1927
+ lines.push(` jig.config.json exempt: "${entry}" is ignored: ${why}`);
1928
+ }
1785
1929
  if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
1786
1930
  const n = meta.exempt?.length ?? 0;
1787
1931
  lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
@@ -1789,6 +1933,9 @@ function formatReport(findings, meta) {
1789
1933
  const note = count === 0 ? meta.scope === "changed" ? "matches nothing among the changed files \u2014 not necessarily wrong" : "matches nothing \u2014 check the path" : tooBroad ? `${count} files \u2014 likely too broad, review it` : `${count} file${count > 1 ? "s" : ""}`;
1790
1934
  lines.push(` ${pattern} (${note})`);
1791
1935
  }
1936
+ for (const { pattern, suggest } of meta.exemptPatterns) {
1937
+ if (suggest?.length) lines.push(` ${pattern}: did you mean ${suggest.map((f) => `"${f}"`).join(" or ")}? Paths start at the project folder.`);
1938
+ }
1792
1939
  if (n > 0) {
1793
1940
  lines.push(` ${meta.exempt.slice(0, 8).join(", ")}${n > 8 ? `, and ${n - 8} more` : ""}`);
1794
1941
  }
@@ -1951,9 +2098,142 @@ function isReaderText(file) {
1951
2098
  return hasExtension(file, STYLE_HOST_EXTENSIONS);
1952
2099
  }
1953
2100
 
2101
+ // src/check/length.ts
2102
+ var LENGTH_UNITS = [
2103
+ "px",
2104
+ "rem",
2105
+ "em",
2106
+ "pt",
2107
+ "vh",
2108
+ "vw",
2109
+ "vmin",
2110
+ "vmax",
2111
+ "ch",
2112
+ "ex"
2113
+ ];
2114
+ var UNITS = LENGTH_UNITS.join("|");
2115
+ var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
2116
+ var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
2117
+ function isExcludedLength(value, unit) {
2118
+ if (value === 0) return true;
2119
+ return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
2120
+ }
2121
+ function hasHardCodedLength(value) {
2122
+ LENGTH_SCAN_RE.lastIndex = 0;
2123
+ let m;
2124
+ while (m = LENGTH_SCAN_RE.exec(value)) {
2125
+ if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
2126
+ }
2127
+ return false;
2128
+ }
2129
+
2130
+ // src/check/tailwind.ts
2131
+ var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2132
+ function readBraced(source, open) {
2133
+ let depth = 0;
2134
+ for (let i = open; i < source.length; i++) {
2135
+ const ch = source[i];
2136
+ if (ch === '"' || ch === "'" || ch === "`") {
2137
+ const quote = ch;
2138
+ i++;
2139
+ while (i < source.length && source[i] !== quote) {
2140
+ if (source[i] === "\\") i++;
2141
+ i++;
2142
+ }
2143
+ continue;
2144
+ }
2145
+ if (ch === "{") depth++;
2146
+ else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2147
+ }
2148
+ return null;
2149
+ }
2150
+ function classAttributeValues(source) {
2151
+ const out = [];
2152
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2153
+ for (const m of source.matchAll(CLASS_ATTR)) {
2154
+ const at = m.index + m[0].length;
2155
+ const opener = source[at];
2156
+ if (opener === '"' || opener === "'") {
2157
+ const end = source.indexOf(opener, at + 1);
2158
+ if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2159
+ continue;
2160
+ }
2161
+ if (opener !== "{") continue;
2162
+ const raw = readBraced(source, at);
2163
+ if (raw === null) continue;
2164
+ const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2165
+ (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2166
+ );
2167
+ if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2168
+ }
2169
+ return out;
2170
+ }
2171
+ var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2172
+ var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2173
+ var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
2174
+ function arbitraryValues(classes) {
2175
+ const out = [];
2176
+ for (const m of classes.matchAll(ARBITRARY)) {
2177
+ const [, utility, rawValue] = m;
2178
+ const value = rawValue.replace(/_/g, " ");
2179
+ if (/var\(|--/.test(value)) continue;
2180
+ if (COLOUR.test(value)) {
2181
+ out.push({ utility, value, kind: "colour" });
2182
+ continue;
2183
+ }
2184
+ if (!LENGTH_RE.test(value)) continue;
2185
+ const parts = LENGTH_PARTS.exec(value);
2186
+ if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
2187
+ out.push({ utility, value, kind: "length" });
2188
+ }
2189
+ return out;
2190
+ }
2191
+ var PALETTE = {
2192
+ white: "#ffffff",
2193
+ black: "#000000",
2194
+ "gray-50": "#f9fafb",
2195
+ "gray-100": "#f3f4f6",
2196
+ "gray-200": "#e5e7eb",
2197
+ "gray-300": "#d1d5db",
2198
+ "gray-400": "#9ca3af",
2199
+ "gray-500": "#6b7280",
2200
+ "gray-600": "#4b5563",
2201
+ "gray-700": "#374151",
2202
+ "gray-800": "#1f2937",
2203
+ "gray-900": "#111827",
2204
+ "gray-950": "#030712",
2205
+ "slate-50": "#f8fafc",
2206
+ "slate-400": "#94a3b8",
2207
+ "slate-500": "#64748b",
2208
+ "slate-800": "#1e293b",
2209
+ "slate-900": "#0f172a",
2210
+ "slate-950": "#020617",
2211
+ "zinc-400": "#a1a1aa",
2212
+ "zinc-500": "#71717a",
2213
+ "zinc-900": "#18181b",
2214
+ "neutral-400": "#a3a3a3",
2215
+ "neutral-500": "#737373",
2216
+ "neutral-900": "#171717",
2217
+ "stone-400": "#a8a29e",
2218
+ "stone-500": "#78716c",
2219
+ "stone-900": "#1c1917"
2220
+ };
2221
+ function palettePairs(classes) {
2222
+ let background;
2223
+ let foreground;
2224
+ for (const token of classes.split(/\s+/)) {
2225
+ const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
2226
+ const bg = /^bg-(.+)$/.exec(bare2);
2227
+ const fg = /^text-(.+)$/.exec(bare2);
2228
+ if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2229
+ if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2230
+ }
2231
+ return background && foreground ? [{ background, foreground }] : [];
2232
+ }
2233
+
1954
2234
  // src/check/run.ts
1955
- import { readFileSync as readFileSync13 } from "fs";
1956
- import { join as join18 } from "path";
2235
+ import { readFileSync as readFileSync15 } from "fs";
2236
+ import { join as join19 } from "path";
1957
2237
 
1958
2238
  // src/check/css.ts
1959
2239
  function maskComments(source) {
@@ -2757,139 +3037,6 @@ var pureBlackWhite = {
2757
3037
  }
2758
3038
  };
2759
3039
 
2760
- // src/check/length.ts
2761
- var LENGTH_UNITS = [
2762
- "px",
2763
- "rem",
2764
- "em",
2765
- "pt",
2766
- "vh",
2767
- "vw",
2768
- "vmin",
2769
- "vmax",
2770
- "ch",
2771
- "ex"
2772
- ];
2773
- var UNITS = LENGTH_UNITS.join("|");
2774
- var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
2775
- var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
2776
- function isExcludedLength(value, unit) {
2777
- if (value === 0) return true;
2778
- return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
2779
- }
2780
- function hasHardCodedLength(value) {
2781
- LENGTH_SCAN_RE.lastIndex = 0;
2782
- let m;
2783
- while (m = LENGTH_SCAN_RE.exec(value)) {
2784
- if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
2785
- }
2786
- return false;
2787
- }
2788
-
2789
- // src/check/tailwind.ts
2790
- var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2791
- function readBraced(source, open) {
2792
- let depth = 0;
2793
- for (let i = open; i < source.length; i++) {
2794
- const ch = source[i];
2795
- if (ch === '"' || ch === "'" || ch === "`") {
2796
- const quote = ch;
2797
- i++;
2798
- while (i < source.length && source[i] !== quote) {
2799
- if (source[i] === "\\") i++;
2800
- i++;
2801
- }
2802
- continue;
2803
- }
2804
- if (ch === "{") depth++;
2805
- else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2806
- }
2807
- return null;
2808
- }
2809
- function classAttributeValues(source) {
2810
- const out = [];
2811
- const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2812
- for (const m of source.matchAll(CLASS_ATTR)) {
2813
- const at = m.index + m[0].length;
2814
- const opener = source[at];
2815
- if (opener === '"' || opener === "'") {
2816
- const end = source.indexOf(opener, at + 1);
2817
- if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2818
- continue;
2819
- }
2820
- if (opener !== "{") continue;
2821
- const raw = readBraced(source, at);
2822
- if (raw === null) continue;
2823
- const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2824
- (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2825
- );
2826
- if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2827
- }
2828
- return out;
2829
- }
2830
- var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2831
- var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2832
- var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
2833
- function arbitraryValues(classes) {
2834
- const out = [];
2835
- for (const m of classes.matchAll(ARBITRARY)) {
2836
- const [, utility, rawValue] = m;
2837
- const value = rawValue.replace(/_/g, " ");
2838
- if (/var\(|--/.test(value)) continue;
2839
- if (COLOUR.test(value)) {
2840
- out.push({ utility, value, kind: "colour" });
2841
- continue;
2842
- }
2843
- if (!LENGTH_RE.test(value)) continue;
2844
- const parts = LENGTH_PARTS.exec(value);
2845
- if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
2846
- out.push({ utility, value, kind: "length" });
2847
- }
2848
- return out;
2849
- }
2850
- var PALETTE = {
2851
- white: "#ffffff",
2852
- black: "#000000",
2853
- "gray-50": "#f9fafb",
2854
- "gray-100": "#f3f4f6",
2855
- "gray-200": "#e5e7eb",
2856
- "gray-300": "#d1d5db",
2857
- "gray-400": "#9ca3af",
2858
- "gray-500": "#6b7280",
2859
- "gray-600": "#4b5563",
2860
- "gray-700": "#374151",
2861
- "gray-800": "#1f2937",
2862
- "gray-900": "#111827",
2863
- "gray-950": "#030712",
2864
- "slate-50": "#f8fafc",
2865
- "slate-400": "#94a3b8",
2866
- "slate-500": "#64748b",
2867
- "slate-800": "#1e293b",
2868
- "slate-900": "#0f172a",
2869
- "slate-950": "#020617",
2870
- "zinc-400": "#a1a1aa",
2871
- "zinc-500": "#71717a",
2872
- "zinc-900": "#18181b",
2873
- "neutral-400": "#a3a3a3",
2874
- "neutral-500": "#737373",
2875
- "neutral-900": "#171717",
2876
- "stone-400": "#a8a29e",
2877
- "stone-500": "#78716c",
2878
- "stone-900": "#1c1917"
2879
- };
2880
- function palettePairs(classes) {
2881
- let background;
2882
- let foreground;
2883
- for (const token of classes.split(/\s+/)) {
2884
- const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
2885
- const bg = /^bg-(.+)$/.exec(bare2);
2886
- const fg = /^text-(.+)$/.exec(bare2);
2887
- if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2888
- if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2889
- }
2890
- return background && foreground ? [{ background, foreground }] : [];
2891
- }
2892
-
2893
3040
  // src/check/detectors/contrast-floor.ts
2894
3041
  var FLOOR = 4.5;
2895
3042
  var LARGE_FLOOR = 3;
@@ -3372,8 +3519,8 @@ var menuState = {
3372
3519
  };
3373
3520
 
3374
3521
  // src/check/declared-properties.ts
3375
- import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
3376
- import { join as join17 } from "path";
3522
+ import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
3523
+ import { join as join18 } from "path";
3377
3524
  var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
3378
3525
  var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
3379
3526
  var SET_PROPERTY_RE = /setProperty\(\s*['"`](--[\w-]+)/g;
@@ -3385,16 +3532,16 @@ function addAll(names, text) {
3385
3532
  }
3386
3533
  }
3387
3534
  function readPackageImport(projectRoot, spec) {
3388
- const base = join17(projectRoot, "node_modules", spec);
3535
+ const base = join18(projectRoot, "node_modules", spec);
3389
3536
  for (const candidate of [base, `${base}.css`]) {
3390
3537
  try {
3391
- if (existsSync10(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
3538
+ if (existsSync11(candidate) && candidate.endsWith(".css")) return readFileSync14(candidate, "utf8");
3392
3539
  } catch {
3393
3540
  }
3394
3541
  }
3395
3542
  try {
3396
- const pkg = JSON.parse(readFileSync12(join17(base, "package.json"), "utf8"));
3397
- if (pkg.style) return readFileSync12(join17(base, pkg.style), "utf8");
3543
+ const pkg = JSON.parse(readFileSync14(join18(base, "package.json"), "utf8"));
3544
+ if (pkg.style) return readFileSync14(join18(base, pkg.style), "utf8");
3398
3545
  } catch {
3399
3546
  }
3400
3547
  return void 0;
@@ -3406,7 +3553,7 @@ function collectDeclaredProperties(projectRoot, files, tokenNames = []) {
3406
3553
  for (const file of files) {
3407
3554
  let raw;
3408
3555
  try {
3409
- raw = readFileSync12(join17(projectRoot, file), "utf8");
3556
+ raw = readFileSync14(join18(projectRoot, file), "utf8");
3410
3557
  } catch {
3411
3558
  continue;
3412
3559
  }
@@ -3484,7 +3631,7 @@ var EM_DASH = /—/;
3484
3631
  var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
3485
3632
  function maskNonProse(source) {
3486
3633
  const blank3 = (m) => " ".repeat(m.length);
3487
- return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3634
+ return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<(blockquote|q|pre|code|samp|kbd)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3488
3635
  }
3489
3636
  function markdownProse(source) {
3490
3637
  const blank3 = (m) => " ".repeat(m.length);
@@ -3767,6 +3914,8 @@ function firstGroup(match) {
3767
3914
  if (!value) return void 0;
3768
3915
  return { value, index: match.index ?? 0 };
3769
3916
  }
3917
+ var PREVIEW_TITLE = /property\s*=\s*["']og:title["']|(?:^|[\s{,])["']?(?:ogTitle|og:title)["']?\s*:|openGraph\s*:\s*\{[^}]*?\btitle\s*:/;
3918
+ var PREVIEW_DESCRIPTION = /property\s*=\s*["']og:description["']|(?:^|[\s{,])["']?(?:ogDescription|og:description)["']?\s*:|openGraph\s*:\s*\{[^}]*?\bdescription\s*:/;
3770
3919
  function readMetadata(source) {
3771
3920
  const htmlTitle = firstGroup(HTML_TITLE.exec(source));
3772
3921
  const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
@@ -3784,6 +3933,9 @@ function readMetadata(source) {
3784
3933
  description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
3785
3934
  hasTitle: ANY_TITLE.test(source),
3786
3935
  hasDescription: ANY_DESCRIPTION.test(source),
3936
+ hasPreviewTitle: PREVIEW_TITLE.test(source),
3937
+ hasPreviewDescription: PREVIEW_DESCRIPTION.test(source),
3938
+ hasPreview: PREVIEW_TITLE.test(source) && PREVIEW_DESCRIPTION.test(source),
3787
3939
  declares: DECLARES.test(source),
3788
3940
  computedTitle: COMPUTED_TITLE.test(source),
3789
3941
  noindex: NOINDEX.some((re) => re.test(source))
@@ -3796,6 +3948,7 @@ var TITLE_BUDGET = 60;
3796
3948
  var DESCRIPTION_BUDGET = 155;
3797
3949
 
3798
3950
  // src/check/detectors/metadata.ts
3951
+ var HEAD_FRAGMENT = /<svelte:head[\s>]|<head[\s>]|<Head[\s>]|<Helmet[\s>]|slot\s*=\s*["']head["']/;
3799
3952
  var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
3800
3953
  var metadata = {
3801
3954
  name: "metadata",
@@ -3829,9 +3982,19 @@ var metadata = {
3829
3982
  if (indexable && !found.hasDescription) {
3830
3983
  at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
3831
3984
  }
3985
+ if (indexable && !found.hasPreview) {
3986
+ const missing = [!found.hasPreviewTitle && "og:title", !found.hasPreviewDescription && "og:description"].filter(Boolean).join(" and ");
3987
+ at("J-164", 0, found.hasPreviewTitle || found.hasPreviewDescription ? `this page has no ${missing} \u2014 a link to it, pasted into a message or a post, shows a card with that part guessed` : "this page has no link preview (og:title and og:description) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry");
3988
+ }
3832
3989
  if (!indexable && !found.noindex) {
3833
3990
  at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
3834
3991
  }
3992
+ } else {
3993
+ const own = HEAD_FRAGMENT.test(raw) ? HTML_DESCRIPTION.exec(raw) ?? HTML_DESCRIPTION_REVERSED.exec(raw) : null;
3994
+ const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
3995
+ if (indexable && own && found.description && !found.hasPreviewDescription) {
3996
+ at("J-164", own.index, `this file writes the page's description but no og:description${found.hasPreviewTitle ? "" : " or og:title"} \u2014 a link to it, pasted into a message or a post, shows a card without the description written here`);
3997
+ }
3835
3998
  }
3836
3999
  return findings.filter((f) => f.ruleId === ctx.ruleId);
3837
4000
  }
@@ -4256,7 +4419,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
4256
4419
  if (applicable.length === 0) continue;
4257
4420
  let raw;
4258
4421
  try {
4259
- raw = readFileSync13(join18(projectRoot, file), "utf8");
4422
+ raw = readFileSync15(join19(projectRoot, file), "utf8");
4260
4423
  } catch {
4261
4424
  continue;
4262
4425
  }
@@ -4270,8 +4433,8 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
4270
4433
  }
4271
4434
 
4272
4435
  // src/check/tokens.ts
4273
- import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
4274
- import { join as join19 } from "path";
4436
+ import { existsSync as existsSync12, readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
4437
+ import { join as join20 } from "path";
4275
4438
  function rootDeclarations(source) {
4276
4439
  const out = {};
4277
4440
  for (const block of splitRuleBlocks(source)) {
@@ -4286,20 +4449,20 @@ function rootDeclarations(source) {
4286
4449
  }
4287
4450
  function loadTokenMap(projectRoot, consumerFiles = []) {
4288
4451
  const jig = {};
4289
- const tokensDir = join19(projectRoot, ".jig", "tokens");
4290
- if (existsSync11(tokensDir)) {
4452
+ const tokensDir = join20(projectRoot, ".jig", "tokens");
4453
+ if (existsSync12(tokensDir)) {
4291
4454
  for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
4292
- Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
4455
+ Object.assign(jig, rootDeclarations(readFileSync16(join20(tokensDir, file), "utf8")));
4293
4456
  }
4294
4457
  }
4295
4458
  const consumer = {};
4296
4459
  const ambiguous = /* @__PURE__ */ new Set();
4297
- const vendored = join19(".jig", "tokens");
4460
+ const vendored = join20(".jig", "tokens");
4298
4461
  for (const file of consumerFiles) {
4299
4462
  if (file.includes(vendored)) continue;
4300
4463
  let source;
4301
4464
  try {
4302
- source = readFileSync14(join19(projectRoot, file), "utf8");
4465
+ source = readFileSync16(join20(projectRoot, file), "utf8");
4303
4466
  } catch {
4304
4467
  continue;
4305
4468
  }
@@ -4318,8 +4481,8 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
4318
4481
  }
4319
4482
 
4320
4483
  // src/check/exempt.ts
4321
- import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
4322
- import { join as join20 } from "path";
4484
+ import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
4485
+ import { join as join21 } from "path";
4323
4486
  function globToRegExp(glob) {
4324
4487
  let out = "";
4325
4488
  for (let i = 0; i < glob.length; i++) {
@@ -4338,18 +4501,37 @@ function globToRegExp(glob) {
4338
4501
  return new RegExp(`^${out}$`);
4339
4502
  }
4340
4503
  function readExemptions(projectRoot) {
4341
- const configPath = join20(projectRoot, "jig.config.json");
4342
- if (!existsSync12(configPath)) return [];
4504
+ const none = { patterns: [], ignored: [] };
4505
+ const configPath = join21(projectRoot, "jig.config.json");
4506
+ if (!existsSync13(configPath)) return none;
4343
4507
  let declared;
4344
4508
  try {
4345
- declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
4509
+ declared = JSON.parse(readFileSync17(configPath, "utf8")).exempt;
4346
4510
  } catch {
4347
- return [];
4511
+ return none;
4512
+ }
4513
+ if (!Array.isArray(declared)) return none;
4514
+ const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
4515
+ const patterns = [];
4516
+ const ignored = [];
4517
+ for (const raw of declared) {
4518
+ if (typeof raw !== "string" || !raw.trim()) continue;
4519
+ let p = raw.trim().replace(/\\/g, "/");
4520
+ if (p.startsWith(`${root}/`)) p = p.slice(root.length + 1);
4521
+ p = p.replace(/^(\.\/)+/, "");
4522
+ if (p.startsWith("/") || /^[A-Za-z]:\//.test(p)) {
4523
+ ignored.push({ entry: raw, why: "an absolute path outside this project. Write it from the project folder, where jig.config.json is: `src/\u2026`" });
4524
+ } else if (p.split("/").includes("..")) {
4525
+ ignored.push({ entry: raw, why: "it leaves the project folder (`..`). Write it from the project folder, where jig.config.json is: `src/\u2026`" });
4526
+ } else {
4527
+ patterns.push(p);
4528
+ }
4348
4529
  }
4349
- if (!Array.isArray(declared)) return [];
4350
- return declared.filter(
4351
- (p) => typeof p === "string" && p.trim() !== "" && !p.startsWith("/") && !p.split("/").includes("..")
4352
- );
4530
+ return { patterns, ignored };
4531
+ }
4532
+ function suggestPaths(pattern, files) {
4533
+ if (pattern.includes("/") || /[*?]/.test(pattern)) return [];
4534
+ return files.filter((f) => f === pattern || f.endsWith(`/${pattern}`)).slice(0, 3);
4353
4535
  }
4354
4536
  var LIKELY_TOO_BROAD = 3;
4355
4537
  function applyExemptions(files, patterns) {
@@ -4374,8 +4556,8 @@ function applyExemptions(files, patterns) {
4374
4556
  }
4375
4557
 
4376
4558
  // src/check/waiver.ts
4377
- import { readFileSync as readFileSync16 } from "fs";
4378
- import { join as join21 } from "path";
4559
+ import { readFileSync as readFileSync18 } from "fs";
4560
+ import { join as join22 } from "path";
4379
4561
  var WAIVER = /jig-allow\s+([A-Z]-\d+(?:\s*,\s*[A-Z]-\d+)*)\s*:(.*)$/;
4380
4562
  function readWaiver(line) {
4381
4563
  const m = WAIVER.exec(line);
@@ -4390,7 +4572,7 @@ function applyWaivers(root, findings) {
4390
4572
  let cached = lines.get(file);
4391
4573
  if (!cached) {
4392
4574
  try {
4393
- cached = readFileSync16(join21(root, file), "utf8").split("\n");
4575
+ cached = readFileSync18(join22(root, file), "utf8").split("\n");
4394
4576
  } catch {
4395
4577
  cached = [];
4396
4578
  }
@@ -4455,8 +4637,8 @@ function hasMenuToggle(raw) {
4455
4637
  }
4456
4638
 
4457
4639
  // src/check/mode-wiring.ts
4458
- import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
4459
- import { dirname as dirname6, join as join22 } from "path";
4640
+ import { existsSync as existsSync14, readFileSync as readFileSync19 } from "fs";
4641
+ import { dirname as dirname6, join as join23 } from "path";
4460
4642
  var MODES = ["editorial", "product", "operator"];
4461
4643
  function configProblems(config) {
4462
4644
  const problems = [];
@@ -4478,10 +4660,10 @@ function configProblems(config) {
4478
4660
  }
4479
4661
  function modeWiringProblems(projectRoot) {
4480
4662
  let config;
4481
- const path = join22(projectRoot, "jig.config.json");
4482
- if (!existsSync13(path)) return [];
4663
+ const path = join23(projectRoot, "jig.config.json");
4664
+ if (!existsSync14(path)) return [];
4483
4665
  try {
4484
- config = JSON.parse(readFileSync17(path, "utf8"));
4666
+ config = JSON.parse(readFileSync19(path, "utf8"));
4485
4667
  } catch {
4486
4668
  return [{ mode: "", barrel: "jig.config.json", message: "jig.config.json is not valid JSON, so nothing in it applies" }];
4487
4669
  }
@@ -4494,10 +4676,10 @@ function modeWiringProblems(projectRoot) {
4494
4676
  const rel = (f) => dir === "." ? f : `${dir}/${f}`;
4495
4677
  const multi = modes.length > 1;
4496
4678
  const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
4497
- const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync13(join22(projectRoot, rel(f))));
4679
+ const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync14(join23(projectRoot, rel(f))));
4498
4680
  if (!anyBarrel) return [];
4499
4681
  const problems = [];
4500
- if (multi && existsSync13(join22(projectRoot, rel("theme.css")))) {
4682
+ if (multi && existsSync14(join23(projectRoot, rel("theme.css")))) {
4501
4683
  problems.push({
4502
4684
  mode: modes[0],
4503
4685
  barrel: rel("theme.css"),
@@ -4509,7 +4691,7 @@ function modeWiringProblems(projectRoot) {
4509
4691
  const modeFile = `mode.${mode}.css`;
4510
4692
  let body;
4511
4693
  try {
4512
- body = readFileSync17(join22(projectRoot, barrel), "utf8");
4694
+ body = readFileSync19(join23(projectRoot, barrel), "utf8");
4513
4695
  } catch {
4514
4696
  problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${barrel} does not exist` });
4515
4697
  return;
@@ -4521,7 +4703,7 @@ function modeWiringProblems(projectRoot) {
4521
4703
  barrel,
4522
4704
  message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
4523
4705
  });
4524
- } else if (!existsSync13(join22(projectRoot, rel(modeFile)))) {
4706
+ } else if (!existsSync14(join23(projectRoot, rel(modeFile)))) {
4525
4707
  problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
4526
4708
  }
4527
4709
  });
@@ -4529,8 +4711,8 @@ function modeWiringProblems(projectRoot) {
4529
4711
  }
4530
4712
 
4531
4713
  // src/check/token-audit.ts
4532
- import { existsSync as existsSync14, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
4533
- import { join as join23 } from "path";
4714
+ import { existsSync as existsSync15, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
4715
+ import { join as join24 } from "path";
4534
4716
  var TEXT_FLOOR = 4.5;
4535
4717
  var UI_FLOOR = 3;
4536
4718
  var FOREGROUNDS = [
@@ -4586,24 +4768,24 @@ function floorFor(token) {
4586
4768
  function tokenFiles(projectRoot) {
4587
4769
  const read = (rel) => {
4588
4770
  try {
4589
- return readFileSync18(join23(projectRoot, ...rel.split("/")), "utf8");
4771
+ return readFileSync20(join24(projectRoot, ...rel.split("/")), "utf8");
4590
4772
  } catch {
4591
4773
  return null;
4592
4774
  }
4593
4775
  };
4594
- const statePath = join23(projectRoot, ".jig", "state.json");
4595
- if (existsSync14(statePath)) {
4776
+ const statePath = join24(projectRoot, ".jig", "state.json");
4777
+ if (existsSync15(statePath)) {
4596
4778
  try {
4597
- const state = JSON.parse(readFileSync18(statePath, "utf8"));
4779
+ const state = JSON.parse(readFileSync20(statePath, "utf8"));
4598
4780
  const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
4599
4781
  const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
4600
4782
  if (out.length > 0) return out;
4601
4783
  } catch {
4602
4784
  }
4603
4785
  }
4604
- const legacy = join23(projectRoot, ".jig", "tokens");
4605
- if (!existsSync14(legacy)) return [];
4606
- return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync18(join23(legacy, f), "utf8") }));
4786
+ const legacy = join24(projectRoot, ".jig", "tokens");
4787
+ if (!existsSync15(legacy)) return [];
4788
+ return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync20(join24(legacy, f), "utf8") }));
4607
4789
  }
4608
4790
  function auditTokenLayer(projectRoot) {
4609
4791
  const files = tokenFiles(projectRoot);
@@ -4671,15 +4853,18 @@ function auditTokenLayer(projectRoot) {
4671
4853
  }
4672
4854
 
4673
4855
  // src/commands/check.ts
4674
- var LEGACY_INDEX_REL = join24(".jig", "rules.index.json");
4856
+ function checkExitCode(result) {
4857
+ return result.hasError ? 1 : 0;
4858
+ }
4859
+ var LEGACY_INDEX_REL = join25(".jig", "rules.index.json");
4675
4860
  function resolveIndexPath(projectRoot) {
4676
- const legacy = join24(projectRoot, LEGACY_INDEX_REL);
4677
- if (existsSync15(legacy)) return legacy;
4678
- return join24(assetRoot(), "rules.index.json");
4861
+ const legacy = join25(projectRoot, LEGACY_INDEX_REL);
4862
+ if (existsSync16(legacy)) return legacy;
4863
+ return join25(assetRoot(), "rules.index.json");
4679
4864
  }
4680
4865
  function resolveMode(projectRoot) {
4681
4866
  try {
4682
- const config = JSON.parse(readFileSync19(join24(projectRoot, "jig.config.json"), "utf8"));
4867
+ const config = JSON.parse(readFileSync21(join25(projectRoot, "jig.config.json"), "utf8"));
4683
4868
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
4684
4869
  if (modes.length === 1) return modes[0];
4685
4870
  return modes.length > 1 ? "mixed" : "unknown";
@@ -4696,24 +4881,25 @@ function summariseUnscanned(files) {
4696
4881
  }
4697
4882
  function countSpecs() {
4698
4883
  try {
4699
- return loadSpecs(join24(assetRoot(), "rules")).length;
4884
+ return loadSpecs(join25(assetRoot(), "rules")).length;
4700
4885
  } catch {
4701
4886
  return 0;
4702
4887
  }
4703
4888
  }
4704
4889
  function check(opts) {
4705
4890
  const indexPath = resolveIndexPath(opts.projectRoot);
4706
- const index = validateIndex(JSON.parse(readFileSync19(indexPath, "utf8")));
4891
+ const index = validateIndex(JSON.parse(readFileSync21(indexPath, "utf8")));
4707
4892
  const selection = selectFiles(opts.projectRoot, opts.all);
4708
- const exemptions = readExemptions(opts.projectRoot);
4709
- const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
4893
+ const { patterns: exemptions, ignored: exemptIgnored } = readExemptions(opts.projectRoot);
4894
+ const { scanned: files, exempt, byPattern: counted } = applyExemptions(selection.files, exemptions);
4895
+ const byPattern = counted.map((p) => ({ ...p, suggest: p.count === 0 && selection.mode !== "changed" ? suggestPaths(p.pattern, selection.files) : [] }));
4710
4896
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
4711
4897
  const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
4712
4898
  const tokens = loadTokenMap(opts.projectRoot, stylesheets);
4713
4899
  const projectParticipates = stylesheets.some((f) => {
4714
4900
  if (!hasExtension(f, CSS_EXTENSIONS)) return false;
4715
4901
  try {
4716
- return participatesInTokenLayer(readFileSync19(join24(opts.projectRoot, f), "utf8"), tokens);
4902
+ return participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
4717
4903
  } catch {
4718
4904
  return false;
4719
4905
  }
@@ -4721,7 +4907,7 @@ function check(opts) {
4721
4907
  const projectResponsive = stylesheets.some((f) => {
4722
4908
  if (!isStyleBearing(f)) return false;
4723
4909
  try {
4724
- const raw = readFileSync19(join24(opts.projectRoot, f), "utf8");
4910
+ const raw = readFileSync21(join25(opts.projectRoot, f), "utf8");
4725
4911
  return isResponsive(maskComments(maskNonStyleRegions(raw, f)), raw);
4726
4912
  } catch {
4727
4913
  return false;
@@ -4730,14 +4916,14 @@ function check(opts) {
4730
4916
  const viewportFitCover = stylesheets.some((f) => {
4731
4917
  if (!isStyleBearing(f)) return false;
4732
4918
  try {
4733
- return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync19(join24(opts.projectRoot, f), "utf8"));
4919
+ return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync21(join25(opts.projectRoot, f), "utf8"));
4734
4920
  } catch {
4735
4921
  return false;
4736
4922
  }
4737
4923
  });
4738
4924
  const projectMenuToggle = stylesheets.some((f) => {
4739
4925
  try {
4740
- return hasMenuToggle(readFileSync19(join24(opts.projectRoot, f), "utf8"));
4926
+ return hasMenuToggle(readFileSync21(join25(opts.projectRoot, f), "utf8"));
4741
4927
  } catch {
4742
4928
  return false;
4743
4929
  }
@@ -4745,8 +4931,10 @@ function check(opts) {
4745
4931
  const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
4746
4932
  const withStyles = files.filter((f) => {
4747
4933
  try {
4748
- const src = readFileSync19(join24(opts.projectRoot, f), "utf8");
4749
- return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
4934
+ const src = readFileSync21(join25(opts.projectRoot, f), "utf8");
4935
+ if (hasExtension(f, CSS_EXTENSIONS)) return src.trim().length > 0;
4936
+ if (maskNonStyleRegions(src, f).trim().length > 0) return true;
4937
+ return isMarkupHost(f) && classAttributeValues(src).some((a) => a.classes.trim().length > 0);
4750
4938
  } catch {
4751
4939
  return false;
4752
4940
  }
@@ -4778,7 +4966,7 @@ function check(opts) {
4778
4966
  // told to add a token @import it has nowhere to put.
4779
4967
  cssFiles.length > 0 && cssFiles.every((f) => {
4780
4968
  try {
4781
- return !participatesInTokenLayer(readFileSync19(join24(opts.projectRoot, f), "utf8"), tokens);
4969
+ return !participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
4782
4970
  } catch {
4783
4971
  return true;
4784
4972
  }
@@ -4798,20 +4986,22 @@ function check(opts) {
4798
4986
  withStyles,
4799
4987
  exempt,
4800
4988
  exemptPatterns: byPattern,
4989
+ exemptIgnored,
4801
4990
  scope: selection.mode,
4802
4991
  modeUnwired: modeWiringProblems(opts.projectRoot),
4992
+ agentFiles: agentFileProblems(opts.projectRoot, opts.homeDir || void 0),
4803
4993
  waived
4804
4994
  });
4805
4995
  return { findings, report: report2, hasError, waived };
4806
4996
  }
4807
4997
 
4808
4998
  // src/commands/init.ts
4809
- import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4810
- import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
4999
+ import { existsSync as existsSync17, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync25, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
5000
+ import { dirname as dirname7, isAbsolute, join as join29, resolve as resolve2, sep as sep3 } from "path";
4811
5001
 
4812
5002
  // src/init/detect.ts
4813
- import { readFileSync as readFileSync20 } from "fs";
4814
- import { join as join25 } from "path";
5003
+ import { readFileSync as readFileSync22 } from "fs";
5004
+ import { join as join26 } from "path";
4815
5005
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
4816
5006
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
4817
5007
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -4828,7 +5018,7 @@ var FRAMEWORK_DEPS = [
4828
5018
  function detectFramework(projectRoot) {
4829
5019
  let pkg;
4830
5020
  try {
4831
- pkg = JSON.parse(readFileSync20(join25(projectRoot, "package.json"), "utf8"));
5021
+ pkg = JSON.parse(readFileSync22(join26(projectRoot, "package.json"), "utf8"));
4832
5022
  } catch {
4833
5023
  return void 0;
4834
5024
  }
@@ -4846,7 +5036,7 @@ function detect(projectRoot) {
4846
5036
  for (const f of cssFiles) {
4847
5037
  let source;
4848
5038
  try {
4849
- source = readFileSync20(join25(projectRoot, f), "utf8");
5039
+ source = readFileSync22(join26(projectRoot, f), "utf8");
4850
5040
  } catch {
4851
5041
  continue;
4852
5042
  }
@@ -4881,8 +5071,8 @@ function isIgnored(projectRoot, relPath) {
4881
5071
  }
4882
5072
 
4883
5073
  // src/init/derive.ts
4884
- import { readFileSync as readFileSync21 } from "fs";
4885
- import { join as join26 } from "path";
5074
+ import { readFileSync as readFileSync23 } from "fs";
5075
+ import { join as join27 } from "path";
4886
5076
  var DEFAULT_PROPOSAL = {
4887
5077
  h: 264,
4888
5078
  s: 0,
@@ -4915,7 +5105,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
4915
5105
  for (const file of cssFiles) {
4916
5106
  let source;
4917
5107
  try {
4918
- source = readFileSync21(join26(projectRoot, file), "utf8");
5108
+ source = readFileSync23(join27(projectRoot, file), "utf8");
4919
5109
  } catch {
4920
5110
  continue;
4921
5111
  }
@@ -4964,7 +5154,7 @@ function balancedBraceBody(source, openIdx) {
4964
5154
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
4965
5155
  let source;
4966
5156
  try {
4967
- source = readFileSync21(join26(projectRoot, tailwindConfigFile), "utf8");
5157
+ source = readFileSync23(join27(projectRoot, tailwindConfigFile), "utf8");
4968
5158
  } catch {
4969
5159
  return null;
4970
5160
  }
@@ -5020,7 +5210,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
5020
5210
  for (const file of cssFiles) {
5021
5211
  let source;
5022
5212
  try {
5023
- source = readFileSync21(join26(projectRoot, file), "utf8");
5213
+ source = readFileSync23(join27(projectRoot, file), "utf8");
5024
5214
  } catch {
5025
5215
  continue;
5026
5216
  }
@@ -5180,8 +5370,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
5180
5370
  }
5181
5371
 
5182
5372
  // src/init/project-name.ts
5183
- import { readFileSync as readFileSync22 } from "fs";
5184
- import { basename, join as join27 } from "path";
5373
+ import { readFileSync as readFileSync24 } from "fs";
5374
+ import { basename, join as join28 } from "path";
5185
5375
  function slugify(name) {
5186
5376
  const withoutScope = name.replace(/^@[^/]+\//, "");
5187
5377
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -5189,7 +5379,7 @@ function slugify(name) {
5189
5379
  }
5190
5380
  function deriveProjectSlug(projectRoot) {
5191
5381
  try {
5192
- const pkg = JSON.parse(readFileSync22(join27(projectRoot, "package.json"), "utf8"));
5382
+ const pkg = JSON.parse(readFileSync24(join28(projectRoot, "package.json"), "utf8"));
5193
5383
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
5194
5384
  } catch {
5195
5385
  }
@@ -5330,7 +5520,7 @@ function parseSurfaces(answer, fallback) {
5330
5520
  return parsed.length > 0 ? parsed : fallback;
5331
5521
  }
5332
5522
  function fileState(projectRoot, absPath, relPath, initManifest) {
5333
- const existsOnDisk = existsSync16(absPath);
5523
+ const existsOnDisk = existsSync17(absPath);
5334
5524
  const tracked = !!initManifest && relPath in initManifest.files;
5335
5525
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
5336
5526
  return { existsOnDisk, tracked, modified };
@@ -5341,11 +5531,11 @@ function isValidSurfaceArray(v) {
5341
5531
  );
5342
5532
  }
5343
5533
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
5344
- if (!existsSync16(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
5534
+ if (!existsSync17(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
5345
5535
  try {
5346
- const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5536
+ const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5347
5537
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
5348
- const brandResolves = brandCandidate ? existsSync16(join28(projectRoot, ...brandCandidate.split("/"))) : false;
5538
+ const brandResolves = brandCandidate ? existsSync17(join29(projectRoot, ...brandCandidate.split("/"))) : false;
5349
5539
  return {
5350
5540
  brand: brandResolves ? brandCandidate : fallbackBrand,
5351
5541
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -5355,10 +5545,10 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
5355
5545
  }
5356
5546
  }
5357
5547
  function configuredBrandPath(projectRoot, configAbsPath) {
5358
- if (!existsSync16(configAbsPath)) return { path: null };
5548
+ if (!existsSync17(configAbsPath)) return { path: null };
5359
5549
  let declared;
5360
5550
  try {
5361
- declared = JSON.parse(readFileSync23(configAbsPath, "utf8")).brand;
5551
+ declared = JSON.parse(readFileSync25(configAbsPath, "utf8")).brand;
5362
5552
  } catch {
5363
5553
  return { path: null };
5364
5554
  }
@@ -5386,9 +5576,9 @@ function defaultTokenDir(detection) {
5386
5576
  return ["jig"];
5387
5577
  }
5388
5578
  function readConfiguredSurfaces(configAbsPath) {
5389
- if (!existsSync16(configAbsPath)) return null;
5579
+ if (!existsSync17(configAbsPath)) return null;
5390
5580
  try {
5391
- const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5581
+ const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5392
5582
  return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
5393
5583
  } catch {
5394
5584
  return null;
@@ -5398,8 +5588,8 @@ function isCssModule(path) {
5398
5588
  return /\.module\.(css|scss)$/i.test(path);
5399
5589
  }
5400
5590
  function legacyBrandFile(projectRoot) {
5401
- const dir = join28(projectRoot, ".jig", "tokens");
5402
- if (!existsSync16(dir)) return void 0;
5591
+ const dir = join29(projectRoot, ".jig", "tokens");
5592
+ if (!existsSync17(dir)) return void 0;
5403
5593
  const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
5404
5594
  return found ? relKey(".jig", "tokens", found) : void 0;
5405
5595
  }
@@ -5430,7 +5620,7 @@ var LEGACY_PAIR_RE = new RegExp(
5430
5620
  `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
5431
5621
  );
5432
5622
  function wireBarrel(absPath, barrelImport) {
5433
- const content = existsSync16(absPath) ? readFileSync23(absPath, "utf8") : "";
5623
+ const content = existsSync17(absPath) ? readFileSync25(absPath, "utf8") : "";
5434
5624
  const line = `@import "${barrelImport}";`;
5435
5625
  if (content.includes(line)) return { status: "already-present" };
5436
5626
  const legacy = LEGACY_PAIR_RE.exec(content);
@@ -5525,7 +5715,7 @@ async function init(opts) {
5525
5715
  else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
5526
5716
  }
5527
5717
  if (opts.yes) {
5528
- const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
5718
+ const declared = readConfiguredSurfaces(join29(opts.projectRoot, "jig.config.json"));
5529
5719
  const inEffect = declared ?? surfaces;
5530
5720
  const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
5531
5721
  log(
@@ -5544,7 +5734,7 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
5544
5734
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
5545
5735
  const projectSlug = deriveProjectSlug(opts.projectRoot);
5546
5736
  const configRelPath = "jig.config.json";
5547
- const configAbsPath = join28(opts.projectRoot, configRelPath);
5737
+ const configAbsPath = join29(opts.projectRoot, configRelPath);
5548
5738
  const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
5549
5739
  if (configuredBrand.refused) {
5550
5740
  log(
@@ -5578,7 +5768,7 @@ The token layer is at ${currentDir}/, and this project's layout suggests ${would
5578
5768
  }
5579
5769
  }
5580
5770
  const brandRelPath = relocateTo ?? configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
5581
- const brandAbsPath = join28(opts.projectRoot, ...brandRelPath.split("/"));
5771
+ const brandAbsPath = join29(opts.projectRoot, ...brandRelPath.split("/"));
5582
5772
  const tokensRelDir = brandRelPath.split("/").slice(0, -1);
5583
5773
  log(
5584
5774
  `Token layer: ${tokensRelDir.join("/") || "."}/ \u2014 ` + (configuredBrand.path ? "from jig.config.json." : priorBrand ? "the layout this project already had. Set `brand` in jig.config.json to move it." : findWireTarget(detection) ? "beside the stylesheet being wired. Set `brand` in jig.config.json to put it elsewhere." : "no stylesheet found to follow, so the project root. Set `brand` in jig.config.json to move it.")
@@ -5611,7 +5801,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5611
5801
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
5612
5802
  }
5613
5803
  if (brandAction === "written") {
5614
- const defaultCssRaw = readFileSync23(join28(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
5804
+ const defaultCssRaw = readFileSync25(join29(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
5615
5805
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
5616
5806
  mkdirSync6(dirname7(brandAbsPath), { recursive: true });
5617
5807
  writeFileSync4(brandAbsPath, content, "utf8");
@@ -5619,7 +5809,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5619
5809
  }
5620
5810
  if (relocateTo && configAction !== "written") {
5621
5811
  try {
5622
- const existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5812
+ const existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5623
5813
  const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
5624
5814
  `;
5625
5815
  writeFileSync4(configAbsPath, content, "utf8");
@@ -5638,12 +5828,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5638
5828
  } else if (configAction === "merged") {
5639
5829
  let existing = {};
5640
5830
  try {
5641
- existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5831
+ existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5642
5832
  } catch {
5643
5833
  existing = {};
5644
5834
  }
5645
5835
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
5646
- const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5836
+ const brandStillResolves = existingBrand ? existsSync17(join29(opts.projectRoot, ...existingBrand.split("/"))) : false;
5647
5837
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5648
5838
  const merged = {
5649
5839
  ...existing,
@@ -5660,12 +5850,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5660
5850
  }
5661
5851
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
5662
5852
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
5663
- const wiringBrandAbsPath = join28(opts.projectRoot, ...effectiveConfig.brand.split("/"));
5853
+ const wiringBrandAbsPath = join29(opts.projectRoot, ...effectiveConfig.brand.split("/"));
5664
5854
  const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
5665
5855
  const modeAbsPaths = {};
5666
5856
  for (const mode of declaredModes) {
5667
5857
  const modeFileName = `mode.${mode}.css`;
5668
- const modeAbsPath2 = join28(opts.projectRoot, ...tokensRelDir, modeFileName);
5858
+ const modeAbsPath2 = join29(opts.projectRoot, ...tokensRelDir, modeFileName);
5669
5859
  modeAbsPaths[mode] = modeAbsPath2;
5670
5860
  const modeRelPath = relKey(...tokensRelDir, modeFileName);
5671
5861
  const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
@@ -5673,7 +5863,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5673
5863
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
5674
5864
  else if (modeState.modified) modeAction = "skipped-edited";
5675
5865
  if (modeAction === "written") {
5676
- const modeSourceRaw = readFileSync23(join28(opts.packageRoot, "tokens", modeFileName), "utf8");
5866
+ const modeSourceRaw = readFileSync25(join29(opts.packageRoot, "tokens", modeFileName), "utf8");
5677
5867
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
5678
5868
  mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
5679
5869
  writeFileSync4(modeAbsPath2, content, "utf8");
@@ -5688,7 +5878,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5688
5878
  const barrelFor = (mode) => multiMode ? `theme.${mode}.css` : "theme.css";
5689
5879
  for (const mode of declaredModes) {
5690
5880
  const rel = relKey(...tokensRelDir, barrelFor(mode));
5691
- const abs = join28(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
5881
+ const abs = join29(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
5692
5882
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5693
5883
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
5694
5884
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
@@ -5701,8 +5891,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5701
5891
  }
5702
5892
  if (multiMode) {
5703
5893
  const rel = relKey(...tokensRelDir, "theme.css");
5704
- const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5705
- if (existsSync16(abs)) {
5894
+ const abs = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
5895
+ if (existsSync17(abs)) {
5706
5896
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5707
5897
  if (state.tracked && !state.modified) {
5708
5898
  rmSync2(abs);
@@ -5717,7 +5907,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5717
5907
  const declared = declaredTokenNames(
5718
5908
  [brandAbsPath, modeAbsPath].map((f) => {
5719
5909
  try {
5720
- return readFileSync23(f, "utf8");
5910
+ return readFileSync25(f, "utf8");
5721
5911
  } catch {
5722
5912
  return "";
5723
5913
  }
@@ -5742,7 +5932,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5742
5932
  log(` This changes how components are written. Both styles are correct.`);
5743
5933
  const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
5744
5934
  if (answer === "y" || answer === "yes") {
5745
- const abs = join28(opts.projectRoot, ...tokensRelDir, "utilities.css");
5935
+ const abs = join29(opts.projectRoot, ...tokensRelDir, "utilities.css");
5746
5936
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5747
5937
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
5748
5938
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
@@ -5753,8 +5943,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5753
5943
  files[rel] = checksum(content);
5754
5944
  const target = findWireTarget(detection);
5755
5945
  const importPath = target ? relativeImportPath(
5756
- dirname7(join28(opts.projectRoot, target)),
5757
- join28(opts.projectRoot, ...tokensRelDir, "utilities.css")
5946
+ dirname7(join29(opts.projectRoot, target)),
5947
+ join29(opts.projectRoot, ...tokensRelDir, "utilities.css")
5758
5948
  ) : `./${rel}`;
5759
5949
  log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
5760
5950
  log(` @import "tailwindcss";`);
@@ -5773,11 +5963,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5773
5963
  writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
5774
5964
  if (relocateTo && currentBrand) {
5775
5965
  const oldDir = currentBrand.split("/").slice(0, -1);
5776
- const oldAbsDir = join28(opts.projectRoot, ...oldDir);
5777
- if (existsSync16(oldAbsDir)) {
5966
+ const oldAbsDir = join29(opts.projectRoot, ...oldDir);
5967
+ if (existsSync17(oldAbsDir)) {
5778
5968
  for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
5779
5969
  const rel = relKey(...oldDir, name);
5780
- const abs = join28(oldAbsDir, name);
5970
+ const abs = join29(oldAbsDir, name);
5781
5971
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5782
5972
  if (!state.tracked || state.modified) {
5783
5973
  log(` ${rel} was edited \u2014 left in place. Delete it yourself once you have moved anything you need.`);
@@ -5791,11 +5981,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5791
5981
  const wireTarget = findWireTarget(detection);
5792
5982
  let wiring;
5793
5983
  if (multiMode) {
5794
- const bare2 = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5984
+ const bare2 = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
5795
5985
  for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
5796
- const abs = join28(opts.projectRoot, file);
5986
+ const abs = join29(opts.projectRoot, file);
5797
5987
  try {
5798
- const before = readFileSync23(abs, "utf8");
5988
+ const before = readFileSync25(abs, "utf8");
5799
5989
  const after = before.split("\n").filter((l) => {
5800
5990
  const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
5801
5991
  return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
@@ -5817,21 +6007,21 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
5817
6007
  snippet: effectiveConfig.surfaces.map((s) => `${s.match} \u2192 ${relKey(...tokensRelDir, barrelFor(s.mode))}`).join("\n")
5818
6008
  };
5819
6009
  } else if (wireTarget) {
5820
- const targetAbsDir = dirname7(join28(opts.projectRoot, wireTarget));
6010
+ const targetAbsDir = dirname7(join29(opts.projectRoot, wireTarget));
5821
6011
  const barrelImport = relativeImportPath(
5822
6012
  targetAbsDir,
5823
- join28(opts.projectRoot, ...tokensRelDir, "theme.css")
6013
+ join29(opts.projectRoot, ...tokensRelDir, "theme.css")
5824
6014
  );
5825
6015
  const snippet = `@import "${barrelImport}";`;
5826
6016
  try {
5827
6017
  if (relocateTo && currentBrand) {
5828
- const abs = join28(opts.projectRoot, wireTarget);
6018
+ const abs = join29(opts.projectRoot, wireTarget);
5829
6019
  const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
5830
- const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
6020
+ const before = existsSync17(abs) ? readFileSync25(abs, "utf8") : "";
5831
6021
  const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
5832
6022
  if (after !== before) writeFileSync4(abs, after, "utf8");
5833
6023
  }
5834
- const outcome = wireBarrel(join28(opts.projectRoot, wireTarget), barrelImport);
6024
+ const outcome = wireBarrel(join29(opts.projectRoot, wireTarget), barrelImport);
5835
6025
  wiring = { target: wireTarget, status: outcome.status, snippet };
5836
6026
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
5837
6027
  log(`
@@ -5848,7 +6038,7 @@ Could not write to ${wireTarget}: ${err.message}`);
5848
6038
  } else {
5849
6039
  const barrelImport = relativeImportPath(
5850
6040
  opts.projectRoot,
5851
- join28(opts.projectRoot, ...tokensRelDir, "theme.css")
6041
+ join29(opts.projectRoot, ...tokensRelDir, "theme.css")
5852
6042
  );
5853
6043
  const snippet = `@import "${barrelImport}";`;
5854
6044
  wiring = { target: null, status: "print-only", snippet };
@@ -5857,7 +6047,7 @@ Could not write to ${wireTarget}: ${err.message}`);
5857
6047
  if (pasteTargets.length > 0) {
5858
6048
  log("Paste the block for whichever of these is your global stylesheet:");
5859
6049
  for (const target of pasteTargets) {
5860
- const dir = dirname7(join28(opts.projectRoot, target));
6050
+ const dir = dirname7(join29(opts.projectRoot, target));
5861
6051
  const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
5862
6052
  @import "${relativeImportPath(dir, modeAbsPath)}";`;
5863
6053
  log(`
@@ -5883,6 +6073,9 @@ Could not write to ${wireTarget}: ${err.message}`);
5883
6073
  "\n WARNING: .jig/ is gitignored. It holds state.json \u2014 which `update` reads to leave your edited files alone \u2014 and the specs, mockups and critique verdicts this project agreed. Ignored, update cannot tell your edits from its own writes, and every spec is invisible to the next agent. Commit it."
5884
6074
  );
5885
6075
  }
6076
+ const agentFiles = ensureAgentFiles(opts.projectRoot, { claude: claudeInstalled(opts.projectRoot, opts.homeDir) });
6077
+ if (agentFiles.length) log(`
6078
+ Wrote Jig's instructions for your agent into ${agentFiles.join(" and ")}; your own text in them is untouched.`);
5886
6079
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
5887
6080
  log("\nBaseline:");
5888
6081
  log(baselineResult.report);
@@ -5905,28 +6098,28 @@ Could not write to ${wireTarget}: ${err.message}`);
5905
6098
 
5906
6099
  // src/commands/verdicts.ts
5907
6100
  import { execFileSync as execFileSync3 } from "child_process";
5908
- import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
5909
- import { join as join37, relative as relative4, resolve as resolve5 } from "path";
6101
+ import { existsSync as existsSync24, readFileSync as readFileSync32 } from "fs";
6102
+ import { join as join38, relative as relative4, resolve as resolve5, sep as sep6 } from "path";
5910
6103
 
5911
6104
  // src/rules/citations.ts
5912
- import { join as join29 } from "path";
6105
+ import { join as join30 } from "path";
5913
6106
  function citableIds(packageRoot2) {
5914
- const rulesDir = join29(packageRoot2, "rules");
6107
+ const rulesDir = join30(packageRoot2, "rules");
5915
6108
  const ids = [
5916
- ...loadRules(rulesDir, join29(packageRoot2, "rules.index.json")).map((r) => r.id),
6109
+ ...loadRules(rulesDir, join30(packageRoot2, "rules.index.json")).map((r) => r.id),
5917
6110
  ...loadSpecs(rulesDir).map((s) => s.id)
5918
6111
  ];
5919
6112
  return [...new Set(ids)].sort();
5920
6113
  }
5921
6114
 
5922
6115
  // src/probe/check.ts
5923
- import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
5924
- import { join as join34 } from "path";
6116
+ import { existsSync as existsSync21, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
6117
+ import { join as join35 } from "path";
5925
6118
 
5926
6119
  // src/probe/save.ts
5927
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync5 } from "fs";
6120
+ import { existsSync as existsSync20, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync28, writeFileSync as writeFileSync5 } from "fs";
5928
6121
  import { fileURLToPath as fileURLToPath2 } from "url";
5929
- import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
6122
+ import { join as join34, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5930
6123
 
5931
6124
  // src/probe/script.ts
5932
6125
  var PROBE_VERSION = 9;
@@ -5947,12 +6140,18 @@ var PROBE_SCRIPT = `(async () => {
5947
6140
  // the page's own copy around it still counts. So is a tool's output or a
5948
6141
  // command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
5949
6142
  // quotes \`jig check\`'s real output, whose own wording has an em dash, and
5950
- // the probe blocked a page for words it had rightly not rewritten.
5951
- let ownText = text;
5952
- for (const q of document.querySelectorAll('blockquote, q, pre, code, samp, kbd')) {
5953
- const quoted = (q.innerText || '').trim();
5954
- if (quoted) ownText = ownText.split(quoted).join('\\n');
5955
- }
6143
+ // the probe blocked a page for words it had rightly not rewritten. The
6144
+ // quotations are hidden while the text is read, not cut out of it by string:
6145
+ // an inline <code>A-01</code> earlier in the prose removed "A-01" from the
6146
+ // output block below it, so the block no longer matched and its dash counted.
6147
+ const quotedEls = [...document.querySelectorAll('blockquote, q, pre, code, samp, kbd')];
6148
+ const hide = document.createElement('style');
6149
+ hide.textContent = '[data-jig-quoted] { display: none !important; }';
6150
+ for (const q of quotedEls) q.setAttribute('data-jig-quoted', '');
6151
+ document.head.appendChild(hide);
6152
+ const ownText = document.body.innerText || '';
6153
+ hide.remove();
6154
+ for (const q of quotedEls) q.removeAttribute('data-jig-quoted');
5956
6155
  const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
5957
6156
  const unresolved = new Set();
5958
6157
  for (const sheet of document.styleSheets) {
@@ -6122,6 +6321,17 @@ var PROBE_SCRIPT = `(async () => {
6122
6321
  fauxSeen.add(key);
6123
6322
  fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
6124
6323
  }
6324
+ // M-01: \`editorial\` allows no box that scrolls sideways on a phone; a code
6325
+ // block wraps and a wide table stacks (L-01, step 6). In a control run a
6326
+ // spec said the code would wrap and the built page scrolled it, and nothing
6327
+ // measured the box: the page itself was not wider than the screen.
6328
+ const sidewaysBoxes = [];
6329
+ for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
6330
+ if (sidewaysBoxes.length >= 5) break;
6331
+ const ox = getComputedStyle(el).overflowX;
6332
+ if ((ox !== 'auto' && ox !== 'scroll') || el.scrollWidth <= el.clientWidth + 1 || !vis(el)) continue;
6333
+ sidewaysBoxes.push({ box: el.tagName.toLowerCase() + (el.classList[0] ? '.' + el.classList[0] : ''), text: (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth });
6334
+ }
6125
6335
  return JSON.stringify({
6126
6336
  jigProbe: ${PROBE_VERSION},
6127
6337
  url: location.href,
@@ -6129,6 +6339,7 @@ var PROBE_SCRIPT = `(async () => {
6129
6339
  scrollWidth: doc.scrollWidth,
6130
6340
  clientWidth: doc.clientWidth,
6131
6341
  sidewaysScroll: doc.scrollWidth > doc.clientWidth,
6342
+ sidewaysBoxes,
6132
6343
  bodyFont: getComputedStyle(document.body).fontFamily,
6133
6344
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
6134
6345
  unresolvedTokens: [...unresolved],
@@ -6145,6 +6356,7 @@ var PROBE_SCRIPT = `(async () => {
6145
6356
  canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
6146
6357
  robots: (document.querySelector('meta[name=robots]') || {}).content || '',
6147
6358
  ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
6359
+ ogDescription: (document.querySelector('meta[property="og:description"]') || {}).content || '',
6148
6360
  ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
6149
6361
  },
6150
6362
  contentWidth: Math.round(region.getBoundingClientRect().width),
@@ -6158,24 +6370,28 @@ var PROBE_SCRIPT = `(async () => {
6158
6370
 
6159
6371
  // src/probe/browser.ts
6160
6372
  import { spawn } from "child_process";
6161
- import { existsSync as existsSync17, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
6162
- import { homedir, tmpdir } from "os";
6163
- import { join as join30 } from "path";
6373
+ import { existsSync as existsSync18, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
6374
+ import { homedir as homedir2, tmpdir } from "os";
6375
+ import { join as join31 } from "path";
6164
6376
  var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
6165
- var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
6377
+ var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser", "chrome", "msedge"];
6166
6378
  var CHROME_APPS = [
6167
6379
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
6168
6380
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
6169
6381
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
6170
6382
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
6171
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
6383
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
6384
+ // Every Windows machine has Edge; Chrome is often installed per user.
6385
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
6386
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
6387
+ ...process.env.LOCALAPPDATA ? [join31(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")] : []
6172
6388
  ];
6173
6389
  function cachedBrowsers() {
6174
6390
  const roots = [
6175
- join30(homedir(), ".cache", "ms-playwright"),
6176
- join30(homedir(), "Library", "Caches", "ms-playwright"),
6177
- join30(homedir(), "AppData", "Local", "ms-playwright"),
6178
- join30(homedir(), ".cache", "puppeteer", "chrome")
6391
+ join31(homedir2(), ".cache", "ms-playwright"),
6392
+ join31(homedir2(), "Library", "Caches", "ms-playwright"),
6393
+ join31(homedir2(), "AppData", "Local", "ms-playwright"),
6394
+ join31(homedir2(), ".cache", "puppeteer", "chrome")
6179
6395
  ];
6180
6396
  const found = [];
6181
6397
  for (const root of roots) {
@@ -6195,8 +6411,8 @@ function cachedBrowsers() {
6195
6411
  ["chrome-win", "chrome.exe"],
6196
6412
  ["chrome-win64", "chrome.exe"]
6197
6413
  ]) {
6198
- const candidate = join30(root, entry, ...rel);
6199
- if (existsSync17(candidate)) found.push(candidate);
6414
+ const candidate = join31(root, entry, ...rel);
6415
+ if (existsSync18(candidate)) found.push(candidate);
6200
6416
  }
6201
6417
  }
6202
6418
  }
@@ -6205,21 +6421,25 @@ function cachedBrowsers() {
6205
6421
  function findChrome() {
6206
6422
  for (const key of CHROME_ENV) {
6207
6423
  const value = process.env[key];
6208
- if (value && existsSync17(value)) return value;
6424
+ if (value && existsSync18(value)) return value;
6209
6425
  }
6210
6426
  for (const command of CHROME_COMMANDS) {
6211
6427
  const resolved = which(command);
6212
6428
  if (resolved) return resolved;
6213
6429
  }
6214
- for (const app of CHROME_APPS) if (existsSync17(app)) return app;
6430
+ for (const app of CHROME_APPS) if (existsSync18(app)) return app;
6215
6431
  return cachedBrowsers()[0];
6216
6432
  }
6217
6433
  function which(command) {
6218
- const dirs = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
6434
+ const windows = process.platform === "win32";
6435
+ const dirs = (process.env.PATH ?? "").split(windows ? ";" : ":");
6436
+ const exts = windows ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""];
6219
6437
  for (const dir of dirs) {
6220
6438
  if (!dir) continue;
6221
- const candidate = join30(dir, command);
6222
- if (existsSync17(candidate)) return candidate;
6439
+ for (const ext of exts) {
6440
+ const candidate = join31(dir, command + ext);
6441
+ if (existsSync18(candidate)) return candidate;
6442
+ }
6223
6443
  }
6224
6444
  return void 0;
6225
6445
  }
@@ -6230,7 +6450,7 @@ async function runProbe(opts) {
6230
6450
  "No browser found to run the probe. Install one, or set JIG_CHROME to a Chrome, Chromium or Edge binary. A project with Playwright or Puppeteer already has one, and Jig will find it."
6231
6451
  );
6232
6452
  }
6233
- const profile = mkdtempSync(join30(tmpdir(), "jig-probe-"));
6453
+ const profile = mkdtempSync(join31(tmpdir(), "jig-probe-"));
6234
6454
  const child = spawn(chrome, [
6235
6455
  "--headless=new",
6236
6456
  "--remote-debugging-port=0",
@@ -6274,6 +6494,9 @@ async function runProbe(opts) {
6274
6494
  }
6275
6495
  }
6276
6496
  async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
6497
+ if (typeof WebSocket === "undefined") {
6498
+ throw new Error(`rendering a page needs Node 22 or later (this is Node ${process.versions.node}). Upgrade Node, then run it again.`);
6499
+ }
6277
6500
  const socket = new WebSocket(endpoint);
6278
6501
  let nextId = 1;
6279
6502
  const pending = /* @__PURE__ */ new Map();
@@ -6331,8 +6554,8 @@ async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
6331
6554
 
6332
6555
  // src/probe/serve.ts
6333
6556
  import { createServer } from "http";
6334
- import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync3 } from "fs";
6335
- import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
6557
+ import { existsSync as existsSync19, readFileSync as readFileSync26, statSync as statSync3 } from "fs";
6558
+ import { extname, join as join32, resolve as resolve3, sep as sep4 } from "path";
6336
6559
  var TYPES = {
6337
6560
  ".html": "text/html; charset=utf-8",
6338
6561
  ".htm": "text/html; charset=utf-8",
@@ -6359,19 +6582,19 @@ async function serveDirectory(dir) {
6359
6582
  const root = resolve3(dir);
6360
6583
  const server = createServer((req, res) => {
6361
6584
  let path = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
6362
- let file = resolve3(join31(root, path));
6585
+ let file = resolve3(join32(root, path));
6363
6586
  if (file !== root && !file.startsWith(root + sep4)) {
6364
6587
  res.writeHead(403).end();
6365
6588
  return;
6366
6589
  }
6367
- if (existsSync18(file) && statSync3(file).isDirectory()) file = join31(file, "index.html");
6368
- else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
6369
- if (!existsSync18(file)) {
6590
+ if (existsSync19(file) && statSync3(file).isDirectory()) file = join32(file, "index.html");
6591
+ else if (!existsSync19(file) && existsSync19(`${file}.html`)) file = `${file}.html`;
6592
+ if (!existsSync19(file)) {
6370
6593
  res.writeHead(404).end();
6371
6594
  return;
6372
6595
  }
6373
6596
  res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream" });
6374
- res.end(readFileSync24(file));
6597
+ res.end(readFileSync26(file));
6375
6598
  });
6376
6599
  await new Promise((done) => server.listen(0, "127.0.0.1", done));
6377
6600
  const address = server.address();
@@ -6383,8 +6606,8 @@ async function serveDirectory(dir) {
6383
6606
  }
6384
6607
 
6385
6608
  // src/probe/switches.ts
6386
- import { readFileSync as readFileSync25 } from "fs";
6387
- import { join as join32 } from "path";
6609
+ import { readFileSync as readFileSync27 } from "fs";
6610
+ import { join as join33 } from "path";
6388
6611
  var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
6389
6612
  function declaredSwitches(projectRoot) {
6390
6613
  const found = /* @__PURE__ */ new Map();
@@ -6392,7 +6615,7 @@ function declaredSwitches(projectRoot) {
6392
6615
  if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
6393
6616
  let text;
6394
6617
  try {
6395
- text = readFileSync25(join32(projectRoot, file), "utf8");
6618
+ text = readFileSync27(join33(projectRoot, file), "utf8");
6396
6619
  } catch {
6397
6620
  continue;
6398
6621
  }
@@ -6427,18 +6650,18 @@ function saveProbe(opts) {
6427
6650
  }
6428
6651
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
6429
6652
  if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6430
- probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
6653
+ probe.pageChecksum = checksum(readFileSync28(page, "utf8"));
6431
6654
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
6432
- const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6655
+ const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6433
6656
  mkdirSync7(dir, { recursive: true });
6434
- const path = join33(dir, `probe-${width}.json`);
6657
+ const path = join34(dir, `probe-${width}.json`);
6435
6658
  writeFileSync5(path, JSON.stringify(probe), "utf8");
6436
6659
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6437
6660
  }
6438
6661
  function projectFile(projectRoot, path) {
6439
6662
  const abs = resolve4(projectRoot, path);
6440
6663
  if (abs !== resolve4(projectRoot) && !abs.startsWith(resolve4(projectRoot) + sep5)) return void 0;
6441
- return existsSync19(abs) ? abs : void 0;
6664
+ return existsSync20(abs) ? abs : void 0;
6442
6665
  }
6443
6666
  function pageFile(projectRoot, url) {
6444
6667
  if (!url.startsWith("file://")) return void 0;
@@ -6450,12 +6673,12 @@ function pageFile(projectRoot, url) {
6450
6673
  }
6451
6674
  const abs = resolve4(path);
6452
6675
  if (!abs.startsWith(resolve4(projectRoot))) return void 0;
6453
- return existsSync19(abs) ? abs : void 0;
6676
+ return existsSync20(abs) ? abs : void 0;
6454
6677
  }
6455
6678
  var PROBE_WIDTHS = [360, 768, 1280, 1600];
6456
6679
  async function runAndSaveProbes(opts) {
6457
6680
  const abs = resolve4(opts.projectRoot, opts.page);
6458
- if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6681
+ if (!existsSync20(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6459
6682
  const saved = [];
6460
6683
  const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
6461
6684
  await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
@@ -6469,7 +6692,7 @@ async function runAndSaveProbes(opts) {
6469
6692
  async function withPageUrl(projectRoot, abs, serve, use) {
6470
6693
  if (!serve) return use(`file://${abs}`);
6471
6694
  const root = resolve4(projectRoot, serve);
6472
- if (!existsSync19(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
6695
+ if (!existsSync20(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
6473
6696
  if (!abs.startsWith(root + sep5)) throw new Error(`The page is not inside ${serve}, so serving ${serve} cannot show it.`);
6474
6697
  const server = await serveDirectory(root);
6475
6698
  try {
@@ -6480,31 +6703,31 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6480
6703
  }
6481
6704
  }
6482
6705
  function recordedPage(projectRoot, surface) {
6483
- const dir = join33(projectRoot, ".jig", "critique", surface);
6484
- if (!existsSync19(dir)) return void 0;
6706
+ const dir = join34(projectRoot, ".jig", "critique", surface);
6707
+ if (!existsSync20(dir)) return void 0;
6485
6708
  for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
6486
6709
  try {
6487
- const probe = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6488
- if (probe.pageFile && existsSync19(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
6710
+ const probe = JSON.parse(readFileSync28(join34(dir, f), "utf8"));
6711
+ if (probe.pageFile && existsSync20(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
6489
6712
  } catch {
6490
6713
  }
6491
6714
  }
6492
6715
  return void 0;
6493
6716
  }
6494
6717
  async function ensureProbes(opts) {
6495
- const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6718
+ const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6496
6719
  const abs = resolve4(opts.projectRoot, opts.page);
6497
6720
  let current;
6498
6721
  try {
6499
- current = checksum(readFileSync26(abs, "utf8"));
6722
+ current = checksum(readFileSync28(abs, "utf8"));
6500
6723
  } catch {
6501
6724
  return { recorded: [], reason: `${opts.page} could not be read` };
6502
6725
  }
6503
- const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6726
+ const recorded = existsSync20(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6504
6727
  const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
6505
6728
  const missing = widths.filter((width) => {
6506
6729
  try {
6507
- const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6730
+ const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
6508
6731
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
6509
6732
  } catch {
6510
6733
  return true;
@@ -6515,7 +6738,7 @@ async function ensureProbes(opts) {
6515
6738
  let serve;
6516
6739
  for (const width of widths) {
6517
6740
  try {
6518
- const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6741
+ const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
6519
6742
  if (probe.serveRoot) {
6520
6743
  serve = probe.serveRoot;
6521
6744
  break;
@@ -6531,9 +6754,9 @@ async function ensureProbes(opts) {
6531
6754
  return { recorded: missing };
6532
6755
  }
6533
6756
  function critiquedSurfaces(projectRoot) {
6534
- const root = join33(projectRoot, ".jig", "critique");
6535
- if (!existsSync19(root)) return [];
6536
- return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
6757
+ const root = join34(projectRoot, ".jig", "critique");
6758
+ if (!existsSync20(root)) return [];
6759
+ return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync20(join34(root, surface, "screen.json")) || existsSync20(join34(root, surface, "code.json"))));
6537
6760
  }
6538
6761
 
6539
6762
  // src/probe/check.ts
@@ -6545,7 +6768,7 @@ function stampProblem(projectRoot, file, p) {
6545
6768
  if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
6546
6769
  let current;
6547
6770
  try {
6548
- current = readFileSync27(page, "utf8");
6771
+ current = readFileSync29(page, "utf8");
6549
6772
  } catch {
6550
6773
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
6551
6774
  }
@@ -6555,11 +6778,11 @@ function stampProblem(projectRoot, file, p) {
6555
6778
  return void 0;
6556
6779
  }
6557
6780
  function readProbes(projectRoot, dir, errors) {
6558
- if (!existsSync20(dir)) return [];
6781
+ if (!existsSync21(dir)) return [];
6559
6782
  const probes = [];
6560
6783
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
6561
6784
  try {
6562
- const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
6785
+ const p = JSON.parse(readFileSync29(join35(dir, f), "utf8"));
6563
6786
  if (typeof p.jigProbe === "number" && p.jigProbe < PROBE_VERSION && typeof p.width === "number") {
6564
6787
  errors.push(`${f} was taken by an older \`jig probe\` (version ${p.jigProbe}; this Jig reads version ${PROBE_VERSION}). Record it again with \`jig probe --run\`.`);
6565
6788
  continue;
@@ -6582,29 +6805,34 @@ function readProbes(projectRoot, dir, errors) {
6582
6805
  }
6583
6806
  var TITLE_BUDGET2 = 60;
6584
6807
  var DESCRIPTION_BUDGET2 = 155;
6585
- function probeContradictions(probes, verdictOf, indexable = true) {
6808
+ function probeCheck(probes, verdictOf, indexable = true, mode) {
6586
6809
  const errors = [];
6810
+ const failures = [];
6587
6811
  const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
6588
6812
  const at = (p) => `probe-${p.width}.json`;
6589
6813
  for (const p of probes) {
6590
6814
  if (p.sidewaysScroll && clean("D-115")) {
6591
6815
  errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
6592
6816
  }
6817
+ if (mode === "editorial" && p.width <= 480 && p.sidewaysBoxes?.length) {
6818
+ const shown = p.sidewaysBoxes.map((b) => `${b.box}${b.text ? ` ("${b.text}")` : ""}, ${b.scrollWidth}px in ${b.clientWidth}px`).join("; ");
6819
+ failures.push(`${at(p)}: ${p.sidewaysBoxes.length === 1 ? "a box scrolls" : `${p.sidewaysBoxes.length} boxes scroll`} sideways on a phone: ${shown}. \`editorial\` allows no scrolling region on mobile (M-01): a code block wraps its long lines, keeping its line breaks and indents, and a wide table stacks its rows (L-01, step 6).`);
6820
+ }
6593
6821
  if (p.defaultFont) {
6594
- errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
6822
+ failures.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
6595
6823
  }
6596
6824
  if (p.fauxFaces?.length) {
6597
6825
  const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
6598
- errors.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
6826
+ failures.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
6599
6827
  }
6600
6828
  if (p.unresolvedTokens.length) {
6601
- errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
6829
+ failures.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
6602
6830
  }
6603
6831
  for (const inv of p.orderInversions ?? []) {
6604
- errors.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
6832
+ failures.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
6605
6833
  }
6606
6834
  if (p.emDashes?.length) {
6607
- errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
6835
+ failures.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
6608
6836
  }
6609
6837
  if (clean("B-106") && p.strandedCount) {
6610
6838
  const shown = (p.strandedWords ?? []).map((w) => `"${w.word}" (in "${w.text}\u2026")`).join(", ");
@@ -6618,17 +6846,21 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6618
6846
  if (head && p.width === probes[0]?.width) {
6619
6847
  const noindex = /noindex/i.test(head.robots);
6620
6848
  if (indexable && !noindex) {
6621
- if (!head.title) errors.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
6622
- else if (head.title.length > TITLE_BUDGET2) errors.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
6623
- if (!head.description) errors.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
6624
- else if (head.description.length > DESCRIPTION_BUDGET2) errors.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
6849
+ if (!head.title) failures.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
6850
+ else if (head.title.length > TITLE_BUDGET2) failures.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
6851
+ if (!head.description) failures.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
6852
+ else if (head.description.length > DESCRIPTION_BUDGET2) failures.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
6853
+ if (typeof head.ogDescription === "string" && (!head.ogTitle || !head.ogDescription)) {
6854
+ const missing = [!head.ogTitle && "og:title", !head.ogDescription && "og:description"].filter(Boolean).join(" and ");
6855
+ failures.push(`${at(p)}: the page served no ${missing} (J-164) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry.`);
6856
+ }
6625
6857
  }
6626
6858
  if (!indexable && !noindex) {
6627
- errors.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
6859
+ failures.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
6628
6860
  }
6629
6861
  }
6630
6862
  if (p.junkText.length) {
6631
- errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
6863
+ failures.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
6632
6864
  }
6633
6865
  }
6634
6866
  const navClean = clean("P-14") || clean("E-61");
@@ -6659,23 +6891,23 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6659
6891
  if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
6660
6892
  }
6661
6893
  }
6662
- return errors;
6894
+ return { contradictions: errors, failures };
6663
6895
  }
6664
6896
 
6665
6897
  // src/check/decisions.ts
6666
- import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
6667
- import { join as join35, posix as posix3 } from "path";
6898
+ import { existsSync as existsSync22, readFileSync as readFileSync30 } from "fs";
6899
+ import { join as join36, posix as posix3 } from "path";
6668
6900
  var HEADING4 = /^(#{2,3})\s+(.+?)\s*$/gm;
6669
6901
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6670
6902
  function decisionsFile(projectRoot) {
6671
6903
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
6672
6904
  try {
6673
- const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
6905
+ const brand = JSON.parse(readFileSync30(join36(projectRoot, "jig.config.json"), "utf8")).brand;
6674
6906
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
6675
6907
  } catch {
6676
6908
  }
6677
6909
  for (const candidate of candidates) {
6678
- if (existsSync21(join35(projectRoot, candidate))) return candidate;
6910
+ if (existsSync22(join36(projectRoot, candidate))) return candidate;
6679
6911
  }
6680
6912
  return void 0;
6681
6913
  }
@@ -6688,7 +6920,7 @@ function decisionHeadings(projectRoot) {
6688
6920
  if (!path) return headings;
6689
6921
  let body;
6690
6922
  try {
6691
- body = readFileSync28(join35(projectRoot, path), "utf8");
6923
+ body = readText(join36(projectRoot, path));
6692
6924
  } catch {
6693
6925
  return headings;
6694
6926
  }
@@ -6746,7 +6978,7 @@ function unsourcedReasons(current, before, opts = {}) {
6746
6978
  }
6747
6979
  return problems;
6748
6980
  }
6749
- function quotesNotFrom(current, before, words) {
6981
+ function quotesNotFrom(current, before, words, source = "the owner's words in tweak.json (`change`)") {
6750
6982
  const problems = [];
6751
6983
  for (const [name, whys] of changedWhys(current, before, true)) {
6752
6984
  for (const why of whys) {
@@ -6754,7 +6986,7 @@ function quotesNotFrom(current, before, words) {
6754
6986
  if (/inferred/i.test(label)) continue;
6755
6987
  const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
6756
6988
  if (missing.length) {
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):**\`.`);
6989
+ problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which ${source} do not say. Quote the owner as they said it, or put your reading under \`**Why (inferred):**\`.`);
6758
6990
  break;
6759
6991
  }
6760
6992
  }
@@ -6768,25 +7000,25 @@ function quoteHeld(quote, words) {
6768
7000
  }
6769
7001
 
6770
7002
  // src/check/spec-shape.ts
6771
- import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
6772
- import { join as join36 } from "path";
7003
+ import { existsSync as existsSync23, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
7004
+ import { join as join37 } from "path";
6773
7005
  function newestSpec(projectRoot) {
6774
- const dir = join36(projectRoot, ".jig", "specs");
6775
- if (!existsSync22(dir)) return void 0;
7006
+ const dir = join37(projectRoot, ".jig", "specs");
7007
+ if (!existsSync23(dir)) return void 0;
6776
7008
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6777
7009
  if (files.length === 0) return void 0;
6778
- const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6779
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
7010
+ const newest = files.map((f) => ({ f, at: statSync4(join37(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
7011
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readText(join37(dir, newest)) };
6780
7012
  }
6781
7013
  function specFor(projectRoot, surface) {
6782
7014
  if (surface) {
6783
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6784
- if (existsSync22(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readFileSync29(path, "utf8") };
7015
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
7016
+ if (existsSync23(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readText(path) };
6785
7017
  }
6786
7018
  return newestSpec(projectRoot);
6787
7019
  }
6788
7020
  function specProblems(spec) {
6789
- const parts = spec.body.split(/^---\s*$/m);
7021
+ const parts = lf(spec.body).split(/^---\s*$/m);
6790
7022
  const front = parts.length >= 3 ? parts[1] : "";
6791
7023
  if (!front.trim()) {
6792
7024
  return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
@@ -6804,7 +7036,7 @@ function specProblems(spec) {
6804
7036
  if (motion) {
6805
7037
  const inline = motion[1].replace(/\s+#.*$/, "").trim();
6806
7038
  const items = motion[2].split("\n").map((l) => l.replace(/^[ \t]+-\s*/, "").trim()).filter(Boolean);
6807
- if (!items.length && !/^none$/i.test(inline)) {
7039
+ if (!items.length && !/^none\b(\s*$|\s*[—–:,;(-])/i.test(inline)) {
6808
7040
  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
7041
  }
6810
7042
  const unsaid = items.filter((i) => !/\bon\b/i.test(i) || !/\btells?\b/i.test(i));
@@ -6875,6 +7107,41 @@ function specIndexableField(front) {
6875
7107
  if (value === "false" || value === "no") return false;
6876
7108
  return "unreadable";
6877
7109
  }
7110
+ var SPEC_FIELDS = [
7111
+ "feature",
7112
+ "surface",
7113
+ "route",
7114
+ "mode",
7115
+ "indexable",
7116
+ "title",
7117
+ "description",
7118
+ "sizes",
7119
+ "switches",
7120
+ "states",
7121
+ "motion",
7122
+ "decisions",
7123
+ "later",
7124
+ "mockup",
7125
+ "mockup_at",
7126
+ "deviations",
7127
+ "critique",
7128
+ "superseded_by",
7129
+ "confirmed"
7130
+ ];
7131
+ var topLevelKeys = (body) => [...(body.split(/^---\s*$/m)[1] ?? "").matchAll(/^([A-Za-z_][\w-]*)\s*:/gm)].map((m) => m[1]);
7132
+ function newFieldProblems(spec, then) {
7133
+ spec = { ...spec, body: lf(spec.body) };
7134
+ then = lf(then);
7135
+ const had = new Set(topLevelKeys(then));
7136
+ const known = new Set(SPEC_FIELDS);
7137
+ const added = [...new Set(topLevelKeys(spec.body))].filter((k) => !known.has(k) && !had.has(k));
7138
+ if (!added.length) return [];
7139
+ const near = (k) => SPEC_FIELDS.find((f) => k.includes(f) || f.startsWith(k.replace(/[-_].*$/, "")));
7140
+ return added.map((k) => {
7141
+ const hint = near(k);
7142
+ return `${spec.path}: \`${k}:\` is not a field Jig reads, so nothing checks what it says. ` + (hint ? `If it is \`${hint}:\`, write it there. ` : "") + `Otherwise put it in the prose below the front matter. Jig's fields: ${SPEC_FIELDS.join(", ")}.`;
7143
+ });
7144
+ }
6878
7145
 
6879
7146
  // src/commands/verdicts.ts
6880
7147
  var VERDICTS = ["ok", "finding", "n/a"];
@@ -6901,19 +7168,19 @@ function reasonProblems(file, judged, errors) {
6901
7168
  }
6902
7169
  }
6903
7170
  function readJson(path, errors) {
6904
- if (!existsSync23(path)) return null;
7171
+ if (!existsSync24(path)) return null;
6905
7172
  try {
6906
- return JSON.parse(readFileSync30(path, "utf8"));
7173
+ return JSON.parse(readFileSync32(path, "utf8"));
6907
7174
  } catch (e) {
6908
7175
  errors.push(`${path}: not valid JSON (${e.message})`);
6909
7176
  return {};
6910
7177
  }
6911
7178
  }
6912
7179
  function specIndexable(projectRoot, surface) {
6913
- const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
7180
+ const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6914
7181
  let front = "";
6915
7182
  try {
6916
- front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
7183
+ front = readText(path).split(/^---\s*$/m)[1] ?? "";
6917
7184
  } catch {
6918
7185
  }
6919
7186
  const declared = specIndexableField(front);
@@ -6922,13 +7189,17 @@ function specIndexable(projectRoot, surface) {
6922
7189
  return mode !== "product" && mode !== "operator";
6923
7190
  }
6924
7191
  function specNeedsNav(projectRoot, surface) {
6925
- const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6926
- if (!existsSync23(path)) return false;
6927
- const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
7192
+ const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
7193
+ if (!existsSync24(path)) return false;
7194
+ const front = readText(path).split(/^---\s*$/m)[1] ?? "";
6928
7195
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6929
7196
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6930
7197
  return navField || navRegion;
6931
7198
  }
7199
+ function unreadFields(label, file, known) {
7200
+ if (!file || typeof file !== "object") return [];
7201
+ return Object.entries(file).filter(([k, v]) => !known.includes(k) && v !== null && typeof v === "object" && Object.keys(v).length > 0).map(([k]) => `${label}: \`${k}\` is not a field Jig reads, so nothing counts what it holds. A rule's verdict goes in \`verdicts\`; what no rule names, in \`differences\`; a note, in the report.`);
7202
+ }
6932
7203
  function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
6933
7204
  const total = required.length + extraRequired.length;
6934
7205
  if (file === null) {
@@ -6986,6 +7257,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6986
7257
  } else findings++;
6987
7258
  }
6988
7259
  }
7260
+ errors.push(...unreadFields(`${name}.json`, file, ["verdicts", "differences", "rendered", "artefacts"]));
6989
7261
  const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
6990
7262
  if (missing.length) {
6991
7263
  errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
@@ -6996,7 +7268,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6996
7268
  function checkDecisions(projectRoot, dir, errors) {
6997
7269
  const required = decisionNames(projectRoot);
6998
7270
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6999
- const file = readJson(join37(dir, "decisions.json"), errors);
7271
+ const file = readJson(join38(dir, "decisions.json"), errors);
7000
7272
  if (!file) {
7001
7273
  errors.push(
7002
7274
  `decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
@@ -7005,6 +7277,7 @@ function checkDecisions(projectRoot, dir, errors) {
7005
7277
  }
7006
7278
  const list = Array.isArray(file.verdicts) ? file.verdicts : [];
7007
7279
  if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
7280
+ errors.push(...unreadFields("decisions.json", file, ["verdicts"]));
7008
7281
  const seen = /* @__PURE__ */ new Set();
7009
7282
  const reasons = [];
7010
7283
  let findings = 0;
@@ -7050,7 +7323,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
7050
7323
  if (!path) return [];
7051
7324
  const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7052
7325
  try {
7053
- const verdictFile = relative4(projectRoot, join37(dir, "decisions.json"));
7326
+ const verdictFile = relative4(projectRoot, join38(dir, "decisions.json")).split(sep6).join("/");
7054
7327
  if (git(["status", "--porcelain", "--", verdictFile])) return [];
7055
7328
  const judgedIn = git(["log", "-1", "--format=%H", "--", verdictFile]);
7056
7329
  if (!judgedIn) return [];
@@ -7074,7 +7347,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
7074
7347
  function verifyVerdicts(opts) {
7075
7348
  const root = opts.packageRoot ?? assetRoot();
7076
7349
  const errors = [];
7077
- const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
7350
+ const index = JSON.parse(readFileSync32(join38(root, "rules.index.json"), "utf8"));
7078
7351
  const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
7079
7352
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
7080
7353
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -7083,9 +7356,9 @@ function verifyVerdicts(opts) {
7083
7356
  ...judgment.map((r) => [r.id, r.pass ?? ""])
7084
7357
  ]);
7085
7358
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
7086
- const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
7087
- const screenFile = readJson(join37(dir, "screen.json"), errors);
7088
- const codeFile = readJson(join37(dir, "code.json"), errors);
7359
+ const dir = join38(opts.projectRoot, ".jig", "critique", opts.surface);
7360
+ const screenFile = readJson(join38(dir, "screen.json"), errors);
7361
+ const codeFile = readJson(join38(dir, "code.json"), errors);
7089
7362
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
7090
7363
  const decisionList = decisionNames(opts.projectRoot);
7091
7364
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
@@ -7093,7 +7366,7 @@ function verifyVerdicts(opts) {
7093
7366
  let rendered = false;
7094
7367
  if (screenFile && screenFile.rendered === true) {
7095
7368
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
7096
- const absent = artefacts.filter((a) => !existsSync23(resolve5(opts.projectRoot, a)));
7369
+ const absent = artefacts.filter((a) => !existsSync24(resolve5(opts.projectRoot, a)));
7097
7370
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
7098
7371
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
7099
7372
  else rendered = true;
@@ -7112,22 +7385,33 @@ function verifyVerdicts(opts) {
7112
7385
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
7113
7386
  return typeof v?.verdict === "string" ? v.verdict : void 0;
7114
7387
  };
7388
+ const measured = [];
7115
7389
  let specFront = "";
7116
7390
  try {
7117
- specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
7391
+ specFront = readText(join38(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`)).split(/^---\s*$/m)[1] ?? "";
7118
7392
  } catch {
7119
7393
  }
7120
7394
  if (specIndexableField(specFront) === "unreadable") {
7121
7395
  errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
7122
7396
  } else {
7123
- errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
7397
+ const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase();
7398
+ const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
7399
+ errors.push(...probed.contradictions);
7400
+ const seen = /* @__PURE__ */ new Set();
7401
+ for (const failure of probed.failures) {
7402
+ const what = failure.replace(/^probe-\d+\.json: /, "");
7403
+ if (!seen.has(what)) {
7404
+ seen.add(what);
7405
+ measured.push(failure);
7406
+ }
7407
+ }
7124
7408
  }
7125
7409
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
7126
7410
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
7127
7411
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
7128
- const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
7412
+ const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}${measured.length ? ` measured=${measured.length}` : ""}`;
7129
7413
  const previous = previousFindings(opts.projectRoot, dir);
7130
- return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
7414
+ return { ok: errors.length === 0, errors, screen, code, decisions, rendered, measured, line, ...previous ? { previous } : {} };
7131
7415
  }
7132
7416
  function previousFindings(projectRoot, dir) {
7133
7417
  const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -7149,7 +7433,7 @@ function previousFindings(projectRoot, dir) {
7149
7433
  const out = /* @__PURE__ */ new Map();
7150
7434
  let text;
7151
7435
  try {
7152
- text = at ? git(["show", `${at}:./${rel}/${f}`]) : readFileSync30(join37(dir, f), "utf8");
7436
+ text = at ? lf(git(["show", `${at}:./${rel}/${f}`])) : readText(join38(dir, f));
7153
7437
  } catch {
7154
7438
  return out;
7155
7439
  }
@@ -7231,18 +7515,17 @@ function sourceChanges(git, from, to) {
7231
7515
  }
7232
7516
 
7233
7517
  // src/commands/gate.ts
7234
- import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7518
+ import { existsSync as existsSync27, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync36, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7235
7519
  import { createHash as createHash3 } from "crypto";
7236
- import { join as join42 } from "path";
7520
+ import { join as join43 } from "path";
7237
7521
 
7238
7522
  // src/check/mockup-drawing.ts
7239
- import { readFileSync as readFileSync31 } from "fs";
7240
- import { join as join38 } from "path";
7523
+ import { join as join39 } from "path";
7241
7524
  var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
7242
7525
  function mockupDrawingProblems(root, specBody, at) {
7243
7526
  let html;
7244
7527
  try {
7245
- html = readFileSync31(join38(root, at), "utf8");
7528
+ html = readText(join39(root, at));
7246
7529
  } catch {
7247
7530
  return [];
7248
7531
  }
@@ -7303,7 +7586,7 @@ function frameRegionProblems(frames, specBody, at) {
7303
7586
  function approvedDrawingProblems(root, specBody, at) {
7304
7587
  let html;
7305
7588
  try {
7306
- html = readFileSync31(join38(root, at), "utf8");
7589
+ html = readText(join39(root, at));
7307
7590
  } catch {
7308
7591
  return [];
7309
7592
  }
@@ -7409,12 +7692,12 @@ function sizeBlock2(sizes, size) {
7409
7692
  import { execFileSync as execFileSync4 } from "child_process";
7410
7693
 
7411
7694
  // src/commands/ship.ts
7412
- import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
7413
- import { join as join40 } from "path";
7695
+ import { existsSync as existsSync25, readdirSync as readdirSync15, readFileSync as readFileSync34 } from "fs";
7696
+ import { join as join41 } from "path";
7414
7697
 
7415
7698
  // src/commands/seo.ts
7416
- import { readFileSync as readFileSync32 } from "fs";
7417
- import { join as join39 } from "path";
7699
+ import { readFileSync as readFileSync33 } from "fs";
7700
+ import { join as join40 } from "path";
7418
7701
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7419
7702
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7420
7703
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -7455,7 +7738,7 @@ function seo(opts) {
7455
7738
  for (const file of files) {
7456
7739
  let raw;
7457
7740
  try {
7458
- raw = readFileSync32(join39(root, file), "utf8");
7741
+ raw = readFileSync33(join40(root, file), "utf8");
7459
7742
  } catch {
7460
7743
  continue;
7461
7744
  }
@@ -7520,7 +7803,7 @@ function critiqueAtShip(projectRoot, specBody) {
7520
7803
  const own = specCritique(specBody);
7521
7804
  if (own) return own === "at-ship";
7522
7805
  try {
7523
- return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7806
+ return JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7524
7807
  } catch {
7525
7808
  return false;
7526
7809
  }
@@ -7528,14 +7811,14 @@ function critiqueAtShip(projectRoot, specBody) {
7528
7811
  function tweakDeferred(dir) {
7529
7812
  let t;
7530
7813
  try {
7531
- t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
7814
+ t = JSON.parse(readFileSync34(join41(dir, "tweak.json"), "utf8"));
7532
7815
  } catch {
7533
7816
  return false;
7534
7817
  }
7535
7818
  if (t.deferred === void 0 || t.deferred === false) return false;
7536
7819
  let judged;
7537
7820
  try {
7538
- judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
7821
+ judged = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).tweak;
7539
7822
  } catch {
7540
7823
  }
7541
7824
  return judged !== t.at;
@@ -7546,15 +7829,16 @@ function pageChecksum(html) {
7546
7829
  function changedSinceLock(projectRoot, dir) {
7547
7830
  let lock;
7548
7831
  try {
7549
- lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
7832
+ lock = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8"));
7550
7833
  } catch {
7551
7834
  return void 0;
7552
7835
  }
7553
7836
  const file = lock.page?.file;
7554
7837
  if (!file || !lock.page?.checksum) return void 0;
7555
7838
  try {
7556
- const html = readFileSync33(join40(projectRoot, file), "utf8");
7557
- return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
7839
+ const html = readFileSync34(join41(projectRoot, file), "utf8");
7840
+ const seen = [pageChecksum(html), checksum(html)];
7841
+ return seen.includes(lock.page.checksum) ? void 0 : file;
7558
7842
  } catch {
7559
7843
  return void 0;
7560
7844
  }
@@ -7564,7 +7848,7 @@ function supersededBy(front) {
7564
7848
  return named ? named : void 0;
7565
7849
  }
7566
7850
  function pageStatus(projectRoot, surface) {
7567
- const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
7851
+ const spec = readText(join41(projectRoot, ".jig", "specs", `${surface}.spec.md`));
7568
7852
  const front = spec.split(/^---\s*$/m)[1] ?? "";
7569
7853
  if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
7570
7854
  return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
@@ -7572,10 +7856,10 @@ function pageStatus(projectRoot, surface) {
7572
7856
  const successor = supersededBy(front);
7573
7857
  if (successor !== void 0) {
7574
7858
  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` };
7859
+ return existsSync25(join41(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
7860
  }
7577
- const dir = join40(projectRoot, ".jig", "critique", surface);
7578
- if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
7861
+ const dir = join41(projectRoot, ".jig", "critique", surface);
7862
+ if (!existsSync25(join41(dir, "screen.json")) && !existsSync25(join41(dir, "code.json"))) {
7579
7863
  return { surface, state: "never", detail: "never critiqued" };
7580
7864
  }
7581
7865
  if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
@@ -7585,13 +7869,17 @@ function pageStatus(projectRoot, surface) {
7585
7869
  const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
7586
7870
  let judgedPage = false;
7587
7871
  try {
7588
- judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
7872
+ judgedPage = !!JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).page?.checksum;
7589
7873
  } catch {
7590
7874
  }
7591
7875
  if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
7592
7876
  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
7877
  if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
7594
7878
  const findings = v.screen.findings + v.code.findings + v.decisions.findings;
7879
+ if (v.measured.length) {
7880
+ const also = findings > 0 ? `, and ${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` : "";
7881
+ return { surface, state: "findings", detail: `the page fails ${v.measured.length} check${v.measured.length === 1 ? "" : "s"} the render probe measured (\`jig verdicts ${surface}\` lists them)${also}` };
7882
+ }
7595
7883
  if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
7596
7884
  return { surface, state: "judged", detail: "judged as it stands, nothing open" };
7597
7885
  }
@@ -7601,17 +7889,19 @@ function ship(opts) {
7601
7889
  const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
7602
7890
  const seoResult = seo({ projectRoot: root });
7603
7891
  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() : [];
7892
+ const specsDir = join41(root, ".jig", "specs");
7893
+ const surfaces = existsSync25(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
7606
7894
  const pages = surfaces.map((s) => pageStatus(root, s));
7607
7895
  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;
7896
+ const agentFiles = agentFileProblems(root);
7897
+ const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0 && agentFiles.length === 0;
7609
7898
  const count = (state) => pages.filter((p) => p.state === state).length;
7610
7899
  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
7900
  const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7", superseded: "\xB7" };
7612
7901
  const report2 = [
7613
7902
  ` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
7614
7903
  ` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
7904
+ ...agentFiles.map((p) => ` \u2717 ${p}`),
7615
7905
  ...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
7616
7906
  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
7907
  ` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
@@ -7621,13 +7911,47 @@ function ship(opts) {
7621
7911
  }
7622
7912
 
7623
7913
  // src/check/spec-checked.ts
7624
- import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
7914
+ import { existsSync as existsSync26, readFileSync as readFileSync35 } from "fs";
7625
7915
  import { createHash as createHash2 } from "crypto";
7626
- import { join as join41 } from "path";
7916
+ import { join as join42 } from "path";
7917
+
7918
+ // src/check/owner-word.ts
7919
+ var SAYS = {
7920
+ confirm: /\b(confirm(s|ed)?|yes|yep|approve[ds]?|looks (good|right|fine)|lgtm|go ahead|agreed|that['’]?s right|correct|ok(ay)?)\b|^\s*go\b/im,
7921
+ approve: /\b(approve[ds]?|looks (good|right|fine)|lgtm|yes|go ahead|good to go|confirm(s|ed)?|agreed|build it|that['’]?s (it|right)|ok(ay)?)\b/i,
7922
+ skip: /\bskip|\bno (mockup|drawing)|without (a )?(mockup|drawing)|(don['’]?t|do not|no need to) (draw|mock)|(mockup|drawing) (isn['’]?t|is not) needed/i,
7923
+ defer: /\b(later|at ship|defer(red)?|wait|leave (it|the critique|the re-?judge)|(don['’]?t|do not) (re-?judge|critique)|no (re-?judge|critique)|skip the (re-?judge|critique))\b/i
7924
+ };
7925
+ function saysSo(word, text) {
7926
+ return SAYS[word].test(text);
7927
+ }
7928
+ var WHAT = {
7929
+ confirm: "confirm the spec",
7930
+ approve: "approve the drawing",
7931
+ skip: "skip the mockup",
7932
+ defer: "leave the re-judge for later"
7933
+ };
7934
+ function ownerWordProblem(word, quoted, owner, where) {
7935
+ const q = quoted.replace(/^["“]|["”]$/g, "").trim();
7936
+ if (!q) return `${where} records the owner's word to ${WHAT[word]} without their words. Quote what they said.`;
7937
+ if (!quoteHeld(q, owner)) return `${where} quotes "${q}", which the owner did not say in this session. Quote their reply as they wrote it; a word nobody gave is not one.`;
7938
+ if (!saysSo(word, q)) return `${where} quotes "${q}", which does not ${WHAT[word]}. Words the owner said about something else are not their word on this: ask them, and quote the answer.`;
7939
+ return void 0;
7940
+ }
7941
+
7942
+ // src/check/spec-checked.ts
7627
7943
  function specChecksum(body) {
7628
- return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7944
+ return createHash2("sha256").update(body.replace(/\r\n/g, "\n").replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7629
7945
  }
7630
- var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7946
+ function mockupField(body) {
7947
+ const raw = /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7948
+ const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
7949
+ if (double) return double[1].replace(/\\(.)/g, "$1").trim();
7950
+ const single = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(raw);
7951
+ if (single) return single[1].replace(/''/g, "'").trim();
7952
+ return raw;
7953
+ }
7954
+ var mockupLine = mockupField;
7631
7955
  function mockupWordProblems(spec, then, owner) {
7632
7956
  const now = mockupLine(spec.body);
7633
7957
  if (now === mockupLine(then)) return [];
@@ -7638,10 +7962,8 @@ function mockupWordProblems(spec, then, owner) {
7638
7962
  if (!quoted) {
7639
7963
  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
7964
  }
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 [];
7965
+ const problem = ownerWordProblem(word === "approved" ? "approve" : "skip", quoted, owner, `${spec.path}: \`mockup: ${word}\``);
7966
+ return problem ? [problem] : [];
7645
7967
  }
7646
7968
  function mockupPending(body) {
7647
7969
  const now = mockupLine(body);
@@ -7676,7 +7998,7 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7676
7998
  const recordPath = `.jig/specs/${spec.slug}.checked.json`;
7677
7999
  let record;
7678
8000
  try {
7679
- record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
8001
+ record = JSON.parse(readFileSync35(join42(root, recordPath), "utf8"));
7680
8002
  } catch {
7681
8003
  problems.push(
7682
8004
  `${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.`
@@ -7705,16 +8027,16 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7705
8027
  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
8028
  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
8029
  const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
7708
- if (!file || !existsSync25(join41(root, file))) return true;
8030
+ if (!file || !existsSync26(join42(root, file))) return true;
7709
8031
  if (!line) return false;
7710
8032
  try {
7711
- return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
8033
+ return Number(line) > readFileSync35(join42(root, file), "utf8").split("\n").length;
7712
8034
  } catch {
7713
8035
  return true;
7714
8036
  }
7715
8037
  });
7716
8038
  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.`);
8039
+ 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: \`source\` is a path from the project root, \`path:line\`, or a URL, and nothing else; what you found there goes in \`note\`.`);
7718
8040
  }
7719
8041
  return problems;
7720
8042
  }
@@ -7722,10 +8044,10 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7722
8044
  // src/commands/gate.ts
7723
8045
  var MAX_BLOCKS = 3;
7724
8046
  function lastJigInvocation(transcriptPath) {
7725
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
8047
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7726
8048
  let text;
7727
8049
  try {
7728
- text = readFileSync35(transcriptPath, "utf8");
8050
+ text = readFileSync36(transcriptPath, "utf8");
7729
8051
  } catch {
7730
8052
  return void 0;
7731
8053
  }
@@ -7750,10 +8072,10 @@ function lastJigInvocation(transcriptPath) {
7750
8072
  return found;
7751
8073
  }
7752
8074
  function ownerWords(transcriptPath) {
7753
- if (!transcriptPath || !existsSync26(transcriptPath)) return "";
8075
+ if (!transcriptPath || !existsSync27(transcriptPath)) return "";
7754
8076
  const said = [];
7755
8077
  try {
7756
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8078
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7757
8079
  if (!line.includes('"user"')) continue;
7758
8080
  let entry;
7759
8081
  try {
@@ -7773,11 +8095,46 @@ function ownerWords(transcriptPath) {
7773
8095
  }
7774
8096
  return said.join("\n");
7775
8097
  }
8098
+ function namedSpec(root, surface) {
8099
+ if (!surface) return specFor(root, void 0);
8100
+ return existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`)) ? specFor(root, surface) : void 0;
8101
+ }
8102
+ function isConfirmed(body) {
8103
+ return /^\s*confirmed\s*:\s*true\b/im.test(body.split(/^---\s*$/m)[1] ?? "");
8104
+ }
8105
+ function ownerReplyAfter(transcriptPath, asked) {
8106
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
8107
+ const turns = [];
8108
+ try {
8109
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
8110
+ let entry;
8111
+ try {
8112
+ entry = JSON.parse(line);
8113
+ } catch {
8114
+ continue;
8115
+ }
8116
+ if (entry.type !== "user" && entry.type !== "assistant" || entry.isMeta) continue;
8117
+ const content = entry.message?.content;
8118
+ const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
8119
+ for (const text of texts) turns.push({ who: entry.type === "user" ? "owner" : "agent", text });
8120
+ }
8121
+ } catch {
8122
+ return void 0;
8123
+ }
8124
+ let answer;
8125
+ turns.forEach((t, i) => {
8126
+ if (t.who !== "agent" || !asked.test(t.text)) return;
8127
+ const reply = [];
8128
+ for (let j = i + 1; j < turns.length && turns[j].who === "owner"; j++) reply.push(turns[j].text);
8129
+ if (reply.length) answer = reply.join("\n");
8130
+ });
8131
+ return answer;
8132
+ }
7776
8133
  function lastAssistantText(transcriptPath) {
7777
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
8134
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7778
8135
  let found;
7779
8136
  try {
7780
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8137
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7781
8138
  if (!line.includes('"assistant"')) continue;
7782
8139
  let entry;
7783
8140
  try {
@@ -7801,9 +8158,9 @@ function asksOwner(text) {
7801
8158
  }
7802
8159
  var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
7803
8160
  function sessionStart(transcriptPath) {
7804
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
8161
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7805
8162
  try {
7806
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8163
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7807
8164
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
7808
8165
  if (m) {
7809
8166
  const t = Date.parse(m[1]);
@@ -7818,20 +8175,20 @@ function verdictsMtime(dir) {
7818
8175
  let newest = 0;
7819
8176
  for (const name of VERDICT_FILES) {
7820
8177
  try {
7821
- newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
8178
+ newest = Math.max(newest, statSync5(join43(dir, name)).mtimeMs);
7822
8179
  } catch {
7823
8180
  }
7824
8181
  }
7825
8182
  return newest;
7826
8183
  }
7827
8184
  function surfacesInPlay(root, command, transcriptPath, surface) {
7828
- const critiqueDir = join42(root, ".jig", "critique");
7829
- if (!existsSync26(critiqueDir)) return [];
8185
+ const critiqueDir = join43(root, ".jig", "critique");
8186
+ if (!existsSync27(critiqueDir)) return [];
7830
8187
  const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7831
8188
  const start = sessionStart(transcriptPath);
7832
8189
  if (start === void 0) return all;
7833
8190
  const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
7834
- return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
8191
+ return all.filter((s) => s === current || verdictsMtime(join43(critiqueDir, s)) >= start - 1e3);
7835
8192
  }
7836
8193
  function surfacesToProbe(root, input) {
7837
8194
  const invocation = lastJigInvocation(input.transcript_path);
@@ -7839,9 +8196,13 @@ function surfacesToProbe(root, input) {
7839
8196
  }
7840
8197
  function fileAtSessionStart(root, path, start) {
7841
8198
  const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
8199
+ try {
8200
+ if (start !== void 0 && statSync5(join43(root, path)).mtimeMs < start - 1e3) return readText(join43(root, path));
8201
+ } catch {
8202
+ }
7842
8203
  try {
7843
8204
  const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
7844
- return base ? git(["show", `${base}:./${path}`]) : "";
8205
+ return base ? lf(git(["show", `${base}:./${path}`])) : "";
7845
8206
  } catch {
7846
8207
  return "";
7847
8208
  }
@@ -7854,7 +8215,7 @@ function changedOutsideRecords(root, start) {
7854
8215
  const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7855
8216
  const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7856
8217
  try {
7857
- return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
8218
+ return start === void 0 || statSync5(join43(root, f)).mtimeMs >= start - 1e3;
7858
8219
  } catch {
7859
8220
  return false;
7860
8221
  }
@@ -7869,8 +8230,8 @@ function drawingChangedAfterApproval(root, specPath, at) {
7869
8230
  try {
7870
8231
  const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7871
8232
  if (!approvedIn) return void 0;
7872
- const then = git(["show", `${approvedIn}:./${at}`]);
7873
- return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
8233
+ const then = lf(git(["show", `${approvedIn}:./${at}`]));
8234
+ return then !== readText(join43(root, at)) ? approvedIn.slice(0, 7) : void 0;
7874
8235
  } catch {
7875
8236
  return void 0;
7876
8237
  }
@@ -7882,18 +8243,25 @@ function commandProblems(root, command, surface, start, owner = "") {
7882
8243
  const found = decisionsFile(root);
7883
8244
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
7884
8245
  else {
7885
- const body = readFileSync35(join42(root, found), "utf8");
8246
+ const body = readText(join43(root, found));
7886
8247
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
7887
8248
  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.`");
7888
8249
  }
7889
8250
  if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
7890
- problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start), { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
8251
+ const then = fileAtSessionStart(root, found, start);
8252
+ problems.push(...unsourcedReasons(body, then, { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
8253
+ if (owner) problems.push(...quotesNotFrom(body, then, owner, "the owner's words in this session").map((p) => `DECISIONS.md: ${p}`));
7891
8254
  }
7892
8255
  }
8256
+ if (command === "spec" && surface && !existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`))) {
8257
+ problems.push(`spec wrote no .jig/specs/${surface}.spec.md. The spec is that file: a draft anywhere else is read by no later step and checked by nothing.`);
8258
+ return problems;
8259
+ }
7893
8260
  if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
7894
8261
  if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
7895
8262
  else {
7896
8263
  problems.push(...specProblems(spec));
8264
+ problems.push(...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start)));
7897
8265
  if (problems.length === 0) problems.push(...navProblems(spec));
7898
8266
  }
7899
8267
  }
@@ -7903,20 +8271,20 @@ function commandProblems(root, command, surface, start, owner = "") {
7903
8271
  }
7904
8272
  if (command === "mockup" && spec) {
7905
8273
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7906
- const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
8274
+ const mockup = mockupField(spec.body);
7907
8275
  if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
7908
8276
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7909
8277
  if (/^approved/i.test(mockup)) {
7910
8278
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
7911
- else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
8279
+ else if (!/^https?:/i.test(at) && !existsSync27(join43(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7912
8280
  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.`);
7913
8281
  }
7914
- if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
8282
+ if (at && /\.html?$/i.test(at) && existsSync27(join43(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7915
8283
  }
7916
- if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
8284
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner, start));
7917
8285
  if ((command === "spec" || command === "make") && spec) {
7918
8286
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7919
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
8287
+ const mockup = mockupField(spec.body);
7920
8288
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7921
8289
  if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
7922
8290
  const drift = approvedDrawingProblems(root, spec.body, at);
@@ -7937,9 +8305,9 @@ function commandProblems(root, command, surface, start, owner = "") {
7937
8305
  }
7938
8306
  if (command === "mockup" && spec) {
7939
8307
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7940
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
8308
+ const mockup = mockupField(spec.body);
7941
8309
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7942
- if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
8310
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
7943
8311
  const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7944
8312
  if (changedAfter) {
7945
8313
  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.`);
@@ -7951,7 +8319,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7951
8319
  if (found) {
7952
8320
  let now = "";
7953
8321
  try {
7954
- now = readFileSync35(join42(root, found), "utf8");
8322
+ now = readText(join43(root, found));
7955
8323
  } catch {
7956
8324
  }
7957
8325
  const then = fileAtSessionStart(root, found, start);
@@ -7965,13 +8333,13 @@ function commandProblems(root, command, surface, start, owner = "") {
7965
8333
  if (found) {
7966
8334
  let now = "";
7967
8335
  try {
7968
- now = readFileSync35(join42(root, found), "utf8");
8336
+ now = readText(join43(root, found));
7969
8337
  } catch {
7970
8338
  }
7971
8339
  const then = fileAtSessionStart(root, found, start);
7972
8340
  if (now && now !== then) {
7973
8341
  problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7974
- const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
8342
+ const change = readTweak(join43(root, ".jig", "critique", spec.slug))?.change;
7975
8343
  if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7976
8344
  }
7977
8345
  }
@@ -7980,7 +8348,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7980
8348
  for (const file of modeFiles(root)) {
7981
8349
  let now = "";
7982
8350
  try {
7983
- now = readFileSync35(join42(root, file), "utf8");
8351
+ now = readText(join43(root, file));
7984
8352
  } catch {
7985
8353
  continue;
7986
8354
  }
@@ -7991,8 +8359,8 @@ function commandProblems(root, command, surface, start, owner = "") {
7991
8359
  }
7992
8360
  }
7993
8361
  if (command === "critique") {
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"))) : [];
8362
+ const dir = join43(root, ".jig", "critique");
8363
+ const surfaces = existsSync27(dir) ? readdirSync16(dir).filter((s) => existsSync27(join43(dir, s, "screen.json")) || existsSync27(join43(dir, s, "code.json"))) : [];
7996
8364
  for (const surface2 of surfaces) {
7997
8365
  const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7998
8366
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -8003,7 +8371,7 @@ function commandProblems(root, command, surface, start, owner = "") {
8003
8371
  }
8004
8372
  }
8005
8373
  const own = specFor(root, surface)?.slug;
8006
- if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
8374
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join43(dir, own), own));
8007
8375
  if (surfaces.length === 0) {
8008
8376
  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.");
8009
8377
  }
@@ -8016,7 +8384,7 @@ function drawingBeforeAsking(root, surface) {
8016
8384
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8017
8385
  const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
8018
8386
  const at = named || `.jig/mockups/${spec.slug}.html`;
8019
- if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
8387
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync27(join43(root, at))) return [];
8020
8388
  const found = mockupDrawingProblems(root, spec.body, at);
8021
8389
  return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
8022
8390
  }
@@ -8024,14 +8392,14 @@ function specChecked(root, spec, start, owner) {
8024
8392
  const found = decisionsFile(root);
8025
8393
  let decisions = "";
8026
8394
  try {
8027
- if (found) decisions = readFileSync35(join42(root, found), "utf8");
8395
+ if (found) decisions = readText(join43(root, found));
8028
8396
  } catch {
8029
8397
  }
8030
8398
  return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
8031
8399
  }
8032
8400
  function modeFiles(root) {
8033
8401
  try {
8034
- const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
8402
+ const state = JSON.parse(readFileSync36(join43(root, ".jig", "state.json"), "utf8"));
8035
8403
  return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
8036
8404
  } catch {
8037
8405
  return [];
@@ -8040,7 +8408,7 @@ function modeFiles(root) {
8040
8408
  function reportOmissions(dir, surface) {
8041
8409
  let report2;
8042
8410
  try {
8043
- report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
8411
+ report2 = readText(join43(dir, "REPORT.md")).toLowerCase();
8044
8412
  } catch {
8045
8413
  return [];
8046
8414
  }
@@ -8048,7 +8416,7 @@ function reportOmissions(dir, surface) {
8048
8416
  for (const f of VERDICT_FILES) {
8049
8417
  let file;
8050
8418
  try {
8051
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8419
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
8052
8420
  } catch {
8053
8421
  continue;
8054
8422
  }
@@ -8061,29 +8429,39 @@ function reportOmissions(dir, surface) {
8061
8429
  if (!missing.length) return [];
8062
8430
  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).`];
8063
8431
  }
8064
- function tweakProblems(root, spec, owner = "") {
8432
+ function tweakProblems(root, spec, owner = "", start) {
8065
8433
  const problems = [];
8066
8434
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8067
8435
  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\`.`);
8068
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
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\`.`);
8436
+ const mockup = mockupField(spec.body);
8437
+ if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup.split(/\s/)[0] || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
8070
8438
  else problems.push(...structureSinceApproval(root, spec, front));
8071
8439
  const surface = spec.slug;
8072
- const record = readTweak(join42(root, ".jig", "critique", surface));
8440
+ const record = readTweak(join43(root, ".jig", "critique", surface));
8073
8441
  if (!record) {
8074
8442
  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\`).`);
8075
8443
  } else {
8076
8444
  if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
8445
+ else if (owner && fileAtSessionStart(root, `.jig/critique/${surface}/tweak.json`, start) !== readText(join43(root, ".jig", "critique", surface, "tweak.json")) && !quoteHeld(record.change.replace(/^["“]|["”]$/g, ""), owner)) {
8446
+ problems.push(`.jig/critique/${surface}/tweak.json: \`change\` is "${record.change.length > 90 ? `${record.change.slice(0, 90)}\u2026` : record.change}", which the owner did not say in this session. It is their words for the change, as they gave them; what you made of them goes in the spec's Tweak entry.`);
8447
+ }
8448
+ try {
8449
+ const raw = JSON.parse(readFileSync36(join43(root, ".jig", "critique", surface, "tweak.json"), "utf8"));
8450
+ const extra = Object.keys(raw).filter((k) => !["at", "change", "ids", "deferred"].includes(k));
8451
+ if (extra.length) problems.push(`.jig/critique/${surface}/tweak.json: ${extra.map((k) => `\`${k}\``).join(", ")} ${extra.length === 1 ? "is not a field" : "are not fields"} Jig reads. tweak.json holds \`at\`, \`change\`, \`ids\` and \`deferred\`; what else you have to say goes in the spec's Tweak entry.`);
8452
+ } catch {
8453
+ }
8077
8454
  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.`);
8078
8455
  if (record.deferred !== void 0) {
8079
- if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
8456
+ const said = typeof record.deferred === "string" ? ownerWordProblem("defer", record.deferred, owner, `.jig/critique/${surface}/tweak.json: \`deferred\``) : void 0;
8457
+ if (typeof record.deferred === "string" ? said !== void 0 : !critiqueAtShip(root, spec.body)) {
8080
8458
  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.`
8459
+ `.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.` + (said ? ` ${said}` : "")
8082
8460
  );
8083
8461
  }
8084
8462
  return problems;
8085
8463
  }
8086
- const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
8464
+ const unjudged = unjudgedTweakIds(join43(root, ".jig", "critique", surface), record);
8087
8465
  if (unjudged.length) {
8088
8466
  problems.push(
8089
8467
  `.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.`
@@ -8110,7 +8488,7 @@ function structureSinceApproval(root, spec, front) {
8110
8488
  const problems = [];
8111
8489
  let thenBody = "";
8112
8490
  try {
8113
- thenBody = git(["show", `${baseline}:./${spec.path}`]);
8491
+ thenBody = lf(git(["show", `${baseline}:./${spec.path}`]));
8114
8492
  } catch {
8115
8493
  return [];
8116
8494
  }
@@ -8119,9 +8497,9 @@ function structureSinceApproval(root, spec, front) {
8119
8497
  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\`.`);
8120
8498
  }
8121
8499
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
8122
- if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
8500
+ if (at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
8123
8501
  try {
8124
- if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
8502
+ if (lf(git(["show", `${baseline}:./${at}`])) !== readText(join43(root, at))) {
8125
8503
  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\`.`);
8126
8504
  }
8127
8505
  } catch {
@@ -8131,7 +8509,7 @@ function structureSinceApproval(root, spec, front) {
8131
8509
  }
8132
8510
  function readTweak(dir) {
8133
8511
  try {
8134
- const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
8512
+ const raw = JSON.parse(readFileSync36(join43(dir, "tweak.json"), "utf8"));
8135
8513
  const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
8136
8514
  return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
8137
8515
  } catch {
@@ -8144,20 +8522,20 @@ function surfacePage(projectRoot, surface) {
8144
8522
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
8145
8523
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
8146
8524
  for (const candidate of candidates) {
8147
- if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
8525
+ if (candidate && existsSync27(join43(projectRoot, candidate))) return candidate;
8148
8526
  }
8149
8527
  return void 0;
8150
8528
  }
8151
8529
  function gate(opts) {
8152
8530
  const root = opts.projectRoot;
8153
- if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
8531
+ if (!existsSync27(join43(root, "jig.config.json")) && !existsSync27(join43(root, ".jig"))) {
8154
8532
  return { block: false, reason: "" };
8155
8533
  }
8156
8534
  const invocation = lastJigInvocation(opts.input.transcript_path);
8157
8535
  const command = invocation?.command;
8158
8536
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
8159
8537
  const start = sessionStart(opts.input.transcript_path);
8160
- const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
8538
+ const owner = command === "decide" || command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
8161
8539
  const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
8162
8540
  if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
8163
8541
  if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
@@ -8171,9 +8549,24 @@ ${result.report}`);
8171
8549
  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
8550
  }
8173
8551
  }
8552
+ if (command === "spec" && !waiting) {
8553
+ const spec = namedSpec(root, invocation?.surface);
8554
+ if (spec && isConfirmed(spec.body) && !isConfirmed(fileAtSessionStart(root, spec.path, start))) {
8555
+ const reply = ownerReplyAfter(opts.input.transcript_path, /\bconfirm/i);
8556
+ const said = reply === void 0 ? void 0 : ownerWordProblem("confirm", reply.slice(0, 400), reply, `${spec.path}: \`confirmed: true\``);
8557
+ if (reply === void 0) problems.push(`/jig spec: ${spec.path} is recorded as confirmed, and the owner was never asked to confirm it. Put it to them with its sheet and stop; record \`confirmed: true\` when they say yes.`);
8558
+ else if (said) problems.push(`/jig spec: ${spec.path} is recorded as confirmed, but the owner's reply to being asked does not confirm it ("${reply.trim().slice(0, 120)}"). Record \`confirmed: true\` only on their yes.`);
8559
+ }
8560
+ }
8174
8561
  if (waiting && command === "spec") {
8175
- const spec = specFor(root, invocation?.surface);
8562
+ const spec = namedSpec(root, invocation?.surface);
8563
+ if (!spec && invocation?.surface && /\bconfirm/i.test(lastAssistantText(opts.input.transcript_path) ?? "")) {
8564
+ problems.push(`/jig spec: you are asking the owner to confirm a spec that is not at .jig/specs/${invocation.surface}.spec.md. Write it there, with \`confirmed: false\`, have it checked (step 3c), and ask again: the checks the owner relies on read that file, and a draft kept anywhere else is checked by nothing.`);
8565
+ }
8176
8566
  if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
8567
+ if (spec && existsSync27(join43(root, ".jig", "specs", `${spec.slug}.checked.json`))) {
8568
+ problems.push(...[...specProblems(spec), ...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start))].map((p) => `/jig spec: ${p}`));
8569
+ }
8177
8570
  }
8178
8571
  const selection = selectFiles(root, false);
8179
8572
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
@@ -8196,11 +8589,17 @@ ${result.report}`);
8196
8589
  }
8197
8590
  }
8198
8591
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
8199
- const critiqueDir = join42(root, ".jig", "critique");
8200
- if (existsSync26(critiqueDir)) {
8592
+ problems.push(...probesLeftBehind(root, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
8593
+ const touched = ["AGENTS.md", "CLAUDE.md"].some((f) => {
8594
+ const now = existsSync27(join43(root, f)) ? readText(join43(root, f)) : "";
8595
+ return fileAtSessionStart(root, f, start) !== now;
8596
+ });
8597
+ if (touched) problems.push(...agentFileProblems(root).map((p) => `Jig's agent instructions: ${p}`));
8598
+ const critiqueDir = join43(root, ".jig", "critique");
8599
+ if (existsSync27(critiqueDir)) {
8201
8600
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
8202
- const dir = join42(critiqueDir, surface);
8203
- if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
8601
+ const dir = join43(critiqueDir, surface);
8602
+ if (!existsSync27(join43(dir, "screen.json")) && !existsSync27(join43(dir, "code.json"))) continue;
8204
8603
  const v = verifyVerdicts({ projectRoot: root, surface });
8205
8604
  if (!v.ok) {
8206
8605
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -8209,6 +8608,13 @@ ${result.report}`);
8209
8608
  ` + shown.join("\n") + (v.errors.length > shown.length ? `
8210
8609
  \u2026 and ${v.errors.length - shown.length} more` : "")
8211
8610
  );
8611
+ } else if (v.measured.length && command !== "critique") {
8612
+ const shown = v.measured.slice(0, 6).map((e) => ` ${e}`);
8613
+ problems.push(
8614
+ `jig verdicts ${surface}: the page fails what the render probe measured. Fix the page, then re-take the probes (\`jig verdicts ${surface} --reprobe\`).
8615
+ ` + shown.join("\n") + (v.measured.length > shown.length ? `
8616
+ \u2026 and ${v.measured.length - shown.length} more` : "")
8617
+ );
8212
8618
  }
8213
8619
  }
8214
8620
  }
@@ -8223,10 +8629,10 @@ ${problems.join("\n\n")}`
8223
8629
  }
8224
8630
  const session = opts.input.session_id ?? "unknown";
8225
8631
  const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
8226
- const stateFile = join42(root, ".jig", "gate.json");
8632
+ const stateFile = join43(root, ".jig", "gate.json");
8227
8633
  let state = {};
8228
8634
  try {
8229
- state = JSON.parse(readFileSync35(stateFile, "utf8"));
8635
+ state = JSON.parse(readFileSync36(stateFile, "utf8"));
8230
8636
  } catch {
8231
8637
  }
8232
8638
  if (problems.length === 0) {
@@ -8261,7 +8667,7 @@ function verdictDigests(dir) {
8261
8667
  for (const f of VERDICT_FILES) {
8262
8668
  let file;
8263
8669
  try {
8264
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8670
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
8265
8671
  } catch {
8266
8672
  continue;
8267
8673
  }
@@ -8276,7 +8682,7 @@ function writeLock(root, surface, lockPath, dir, now) {
8276
8682
  const pageFile2 = recordedPage(root, surface);
8277
8683
  let page;
8278
8684
  try {
8279
- if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
8685
+ if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync36(join43(root, pageFile2), "utf8")) };
8280
8686
  } catch {
8281
8687
  }
8282
8688
  const tweakAt = readTweak(dir)?.at;
@@ -8289,7 +8695,7 @@ function verdictChecksum(dir) {
8289
8695
  const parts = VERDICT_FILES.map((f) => {
8290
8696
  try {
8291
8697
  return `${f}
8292
- ${readFileSync35(join42(dir, f), "utf8")}`;
8698
+ ${readFileSync36(join43(dir, f), "utf8")}`;
8293
8699
  } catch {
8294
8700
  return "";
8295
8701
  }
@@ -8297,15 +8703,15 @@ ${readFileSync35(join42(dir, f), "utf8")}`;
8297
8703
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
8298
8704
  }
8299
8705
  function verdictGuard(root, command, inPlay) {
8300
- const critiqueDir = join42(root, ".jig", "critique");
8301
- if (!existsSync26(critiqueDir)) return [];
8706
+ const critiqueDir = join43(root, ".jig", "critique");
8707
+ if (!existsSync27(critiqueDir)) return [];
8302
8708
  const problems = [];
8303
8709
  for (const surface of readdirSync16(critiqueDir)) {
8304
8710
  if (surface.startsWith("_")) continue;
8305
- const dir = join42(critiqueDir, surface);
8711
+ const dir = join43(critiqueDir, surface);
8306
8712
  const now = verdictChecksum(dir);
8307
8713
  if (!now) continue;
8308
- const lockPath = join42(dir, LOCK);
8714
+ const lockPath = join43(dir, LOCK);
8309
8715
  if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
8310
8716
  writeLock(root, surface, lockPath, dir, now);
8311
8717
  problems.push(...lockLeftBehind(root, surface));
@@ -8313,7 +8719,7 @@ function verdictGuard(root, command, inPlay) {
8313
8719
  }
8314
8720
  let lock = {};
8315
8721
  try {
8316
- lock = JSON.parse(readFileSync35(lockPath, "utf8"));
8722
+ lock = JSON.parse(readFileSync36(lockPath, "utf8"));
8317
8723
  } catch {
8318
8724
  continue;
8319
8725
  }
@@ -8346,6 +8752,25 @@ function lockLeftBehind(root, surface) {
8346
8752
  }
8347
8753
  return [`${rel}/${LOCK} was written when you stopped, after the verdicts it records were committed. Commit it on its own (\`git add ${rel}/${LOCK} && git commit -m "chore: record ${surface}'s verdict lock"\`), and nothing else.`];
8348
8754
  }
8755
+ function probesLeftBehind(root, surfaces) {
8756
+ if (!surfaces.length) return [];
8757
+ let lines;
8758
+ try {
8759
+ lines = execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
8760
+ } catch {
8761
+ return [];
8762
+ }
8763
+ const path = (l) => l.slice(3).replace(/^"|"$/g, "");
8764
+ const isProbe = (p) => /^\.jig\/critique\/[^/]+\/probe-[^/]+\.json$/.test(p);
8765
+ const isLock = (p) => /^\.jig\/critique\/[^/]+\/verdicts\.lock$/.test(p);
8766
+ if (lines.some((l) => !isProbe(path(l)) && !isLock(path(l)))) return [];
8767
+ const left = lines.map(path).filter((p) => isProbe(p) && surfaces.some((s) => p.startsWith(`.jig/critique/${s}/`)));
8768
+ if (!left.length) return [];
8769
+ const bySurface = [...new Set(left.map((p) => p.split("/")[2]))];
8770
+ return [
8771
+ `The gate re-took ${left.length} probe file${left.length === 1 ? "" : "s"} of ${bySurface.join(", ")} when you stopped, after your last commit: the page changed since they were taken. Commit them on their own (\`git add ${bySurface.map((s) => `.jig/critique/${s}/probe-*.json`).join(" ")} && git commit -m "chore: re-take ${bySurface.join(", ")}'s probes"\`), and nothing else.`
8772
+ ];
8773
+ }
8349
8774
  function tweakVerdictProblems(dir, surface, lockedVerdicts) {
8350
8775
  const record = readTweak(dir);
8351
8776
  if (!record) {
@@ -8370,7 +8795,7 @@ function unjudgedTweakIds(dir, record) {
8370
8795
  for (const f of VERDICT_FILES) {
8371
8796
  let file;
8372
8797
  try {
8373
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8798
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
8374
8799
  } catch {
8375
8800
  continue;
8376
8801
  }
@@ -8382,7 +8807,7 @@ function unjudgedTweakIds(dir, record) {
8382
8807
  }
8383
8808
  function save(file, state) {
8384
8809
  try {
8385
- mkdirSync8(join42(file, ".."), { recursive: true });
8810
+ mkdirSync8(join43(file, ".."), { recursive: true });
8386
8811
  writeFileSync6(file, JSON.stringify(state), "utf8");
8387
8812
  } catch {
8388
8813
  }
@@ -8390,7 +8815,7 @@ function save(file, state) {
8390
8815
 
8391
8816
  // src/index.ts
8392
8817
  var packageRoot = getPackageRoot();
8393
- var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
8818
+ var { version } = JSON.parse(readFileSync37(join44(packageRoot, "package.json"), "utf8"));
8394
8819
  function warnIfUnpublishedPin() {
8395
8820
  if (isDevVersion(version)) {
8396
8821
  console.warn(
@@ -8434,7 +8859,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8434
8859
  projectRoot,
8435
8860
  packageRoot: assetRoot(),
8436
8861
  version,
8437
- homeDir: homedir2(),
8862
+ homeDir: homedir3(),
8438
8863
  hook
8439
8864
  });
8440
8865
  if (result.warning) {
@@ -8443,6 +8868,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8443
8868
  }
8444
8869
  if (result.hookOnly) {
8445
8870
  console.log(result.hookOnly);
8871
+ for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
8446
8872
  if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
8447
8873
  if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
8448
8874
  return;
@@ -8451,6 +8877,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8451
8877
  warnIfUnpublishedPin();
8452
8878
  for (const f of result.written) console.log(` + ${f}`);
8453
8879
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
8880
+ for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
8454
8881
  if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
8455
8882
  if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
8456
8883
  if (result.stopHook === void 0 && opts.agent === "claude" && opts.scope === "project") {
@@ -8478,7 +8905,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
8478
8905
  projectRoot,
8479
8906
  packageRoot: assetRoot(),
8480
8907
  version,
8481
- homeDir: homedir2()
8908
+ homeDir: homedir3()
8482
8909
  });
8483
8910
  const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
8484
8911
  warnIfUnpublishedPin();
@@ -8497,6 +8924,16 @@ program.command("update").description("Update vendored Jig rules, skipping files
8497
8924
  process.exit(1);
8498
8925
  }
8499
8926
  });
8927
+ program.command("checksum").description("Print a spec's checksum, the value its .checked.json records as `spec`.").argument("<spec>", "the spec by name (tokens-and-modes) or path (.jig/specs/tokens-and-modes.spec.md)").action((name) => {
8928
+ const projectRoot = findProjectRoot(process.cwd());
8929
+ const slug = name.replace(/^.*\//, "").replace(/\.spec\.md$/, "");
8930
+ const path = join44(projectRoot, ".jig", "specs", `${slug}.spec.md`);
8931
+ if (!existsSync28(path)) {
8932
+ console.error(` \u2717 no spec at .jig/specs/${slug}.spec.md`);
8933
+ process.exit(1);
8934
+ }
8935
+ console.log(specChecksum(readFileSync37(path, "utf8")));
8936
+ });
8500
8937
  program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").option("--reprobe", "re-take every probe that is missing or was taken on an older version of the page, first (needs a browser)").action(async (surface, opts) => {
8501
8938
  const projectRoot = findProjectRoot(process.cwd());
8502
8939
  try {
@@ -8516,6 +8953,10 @@ program.command("verdicts").description("Verify a critique's verdict files and c
8516
8953
  console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
8517
8954
  }
8518
8955
  if (result.ok) console.log(` Every rule in both passes has a verdict.`);
8956
+ if (result.measured.length) {
8957
+ console.log(` The page fails ${result.measured.length} check(s) the render probe measured. They are findings on the page, for make or tweak to fix; a critique reports them and leaves the page as it is:`);
8958
+ for (const failure of result.measured) console.log(` ! ${failure}`);
8959
+ }
8519
8960
  const since = result.decisions.since ?? [];
8520
8961
  if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
8521
8962
  const p = result.previous;
@@ -8536,18 +8977,19 @@ program.command("check").description("Check the repo against Jig's mechanical +
8536
8977
  // this as widening the RULES and attested a clean diff as a clean project.
8537
8978
  "scan every file in the repo, not just those changed since HEAD (same rules either way)",
8538
8979
  false
8539
- ).option("--ci", "mechanical bucket only; exits non-zero on any error, deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
8980
+ ).option("--ci", "mechanical bucket only, so the run is deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
8540
8981
  const projectRoot = findProjectRoot(process.cwd());
8541
8982
  try {
8542
8983
  const result = check({
8543
8984
  projectRoot,
8544
- homeDir: homedir2(),
8985
+ homeDir: homedir3(),
8545
8986
  version,
8546
8987
  all: opts.all,
8547
8988
  ci: opts.ci
8548
8989
  });
8549
8990
  console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
8550
- if (opts.ci && result.hasError) process.exit(1);
8991
+ const code = checkExitCode(result);
8992
+ if (code !== 0) process.exit(code);
8551
8993
  } catch (err) {
8552
8994
  console.error(err.message);
8553
8995
  process.exit(1);
@@ -8624,13 +9066,14 @@ program.command("probe").description("Print the render probe. With --save, read
8624
9066
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
8625
9067
  let input = {};
8626
9068
  try {
8627
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
9069
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync37(0, "utf8") || "{}");
8628
9070
  } catch {
8629
9071
  }
8630
9072
  const cwd = input.cwd ?? process.cwd();
8631
9073
  try {
8632
9074
  const projectRoot = findProjectRoot(cwd);
8633
- const critiqued = critiquedSurfaces(projectRoot);
9075
+ const byHand = !input.transcript_path;
9076
+ const critiqued = byHand ? [] : critiquedSurfaces(projectRoot);
8634
9077
  for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
8635
9078
  const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
8636
9079
  if (!page) continue;
@@ -8653,7 +9096,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8653
9096
  await init({
8654
9097
  projectRoot,
8655
9098
  packageRoot: assetRoot(),
8656
- homeDir: homedir2(),
9099
+ homeDir: homedir3(),
8657
9100
  version,
8658
9101
  yes: opts.yes
8659
9102
  });
@@ -8664,7 +9107,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8664
9107
  });
8665
9108
  function resolveSurfaceMode(projectRoot) {
8666
9109
  try {
8667
- const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
9110
+ const config = JSON.parse(readFileSync37(join44(projectRoot, "jig.config.json"), "utf8"));
8668
9111
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
8669
9112
  return modes.length === 1 ? modes[0] : void 0;
8670
9113
  } catch {