jig-ui 0.22.0 → 0.24.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";
@@ -28,7 +28,7 @@ function assetRoot(startDir = getPackageRoot()) {
28
28
  let current = startDir;
29
29
  const { root } = parse(startDir);
30
30
  while (true) {
31
- if (existsSync(join(current, ASSET_MARKER))) return current;
31
+ if (existsSync(join(current, ASSET_MARKER))) return sourceOf(current);
32
32
  if (current === root) {
33
33
  throw new Error(
34
34
  `assetRoot(): could not find an ancestor of "${startDir}" containing "${ASSET_MARKER}"`
@@ -37,6 +37,11 @@ function assetRoot(startDir = getPackageRoot()) {
37
37
  current = dirname(current);
38
38
  }
39
39
  }
40
+ function sourceOf(found) {
41
+ if (isPublishedBuild(found)) return found;
42
+ const repo = join(found, "..", "..");
43
+ return existsSync(join(repo, ASSET_MARKER)) && existsSync(join(repo, "packages", "cli", "package.json")) ? repo : found;
44
+ }
40
45
  function isPublishedBuild(packageRoot2) {
41
46
  return packageRoot2.split(/[\\/]/).includes("node_modules");
42
47
  }
@@ -50,8 +55,8 @@ function cliInvocation(version2) {
50
55
  var JIG_GATE_HOOK = /(?:\bjig-ui@[^\s"]+|^jig) gate\b/;
51
56
 
52
57
  // src/commands/install.ts
53
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
54
- 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";
55
60
 
56
61
  // src/adapters/types.ts
57
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.";
@@ -83,6 +88,236 @@ function agentsPointer(skillPath) {
83
88
  ].join("\n");
84
89
  }
85
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
+
86
321
  // src/adapters/codex.ts
87
322
  var codex = {
88
323
  name: "codex",
@@ -91,7 +326,7 @@ var codex = {
91
326
  const skillPath = ".agents/skills/jig/SKILL.md";
92
327
  const files = [{ relPath: skillPath, content: ctx.skillBody }];
93
328
  const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
94
- files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
329
+ files.push({ relPath: agentsPath, content: ctx.scope === "global" ? agentsPointer(skillPath) : agentsGuide() });
95
330
  if (ctx.commandBody && ctx.scope === "global") {
96
331
  files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
97
332
  ` });
@@ -253,22 +488,22 @@ function skillFilesFor(adapter, ctx) {
253
488
  }
254
489
 
255
490
  // src/install/references.ts
256
- import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
257
- 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";
258
493
  function referenceFiles(packageRoot2) {
259
- const root = join2(packageRoot2, "references");
260
- if (!existsSync2(root)) return [];
494
+ const root = join5(packageRoot2, "references");
495
+ if (!existsSync3(root)) return [];
261
496
  const found = [];
262
497
  const walk2 = (dir, prefix) => {
263
498
  for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
264
499
  (a, b) => a.name.localeCompare(b.name)
265
500
  )) {
266
- const child = join2(dir, entry.name);
267
- 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;
268
503
  if (entry.isDirectory()) {
269
504
  walk2(child, rel);
270
505
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
271
- found.push({ relPath: rel, content: readFileSync(child, "utf8") });
506
+ found.push({ relPath: rel, content: readFileSync3(child, "utf8") });
272
507
  }
273
508
  }
274
509
  };
@@ -278,32 +513,11 @@ function referenceFiles(packageRoot2) {
278
513
 
279
514
  // src/install/manifest.ts
280
515
  import { createHash } from "crypto";
281
- import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
282
- import { dirname as dirname3, join as join4 } from "path";
283
-
284
- // src/install/atomic.ts
285
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
286
- import { dirname as dirname2, join as join3 } from "path";
287
- function writeFileAtomic(path, content) {
288
- const dir = dirname2(path);
289
- mkdirSync(dir, { recursive: true });
290
- const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
291
- try {
292
- writeFileSync(tmp, content, "utf8");
293
- renameSync(tmp, path);
294
- } catch (err) {
295
- try {
296
- unlinkSync(tmp);
297
- } catch {
298
- }
299
- throw err;
300
- }
301
- }
302
-
303
- // 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";
304
518
  var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
305
519
  function manifestPath(root, manifestDir) {
306
- return join4(root, ...manifestDir.split("/"), "manifest.json");
520
+ return join6(root, ...manifestDir.split("/"), "manifest.json");
307
521
  }
308
522
  function checksum(content) {
309
523
  const normalized = content.replace(/\r\n/g, "\n");
@@ -341,10 +555,10 @@ function validateManifest(raw, path) {
341
555
  }
342
556
  function readManifest(root, manifestDir = ".jig") {
343
557
  const path = manifestPath(root, manifestDir);
344
- if (!existsSync3(path)) return null;
558
+ if (!existsSync4(path)) return null;
345
559
  let raw;
346
560
  try {
347
- raw = JSON.parse(readFileSync2(path, "utf8"));
561
+ raw = JSON.parse(readFileSync4(path, "utf8"));
348
562
  } catch {
349
563
  throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
350
564
  }
@@ -374,12 +588,9 @@ function withLock(lock, fn) {
374
588
  }
375
589
  break;
376
590
  } catch (err) {
377
- if (err.code !== "EEXIST") throw err;
591
+ if (!BUSY.has(err.code ?? "")) throw err;
378
592
  if (lockIsStale(lock) || Date.now() > deadline) {
379
- try {
380
- unlinkSync2(lock);
381
- } catch {
382
- }
593
+ unlinkRetrying(lock);
383
594
  continue;
384
595
  }
385
596
  sleep(10 + Math.random() * 20);
@@ -388,15 +599,25 @@ function withLock(lock, fn) {
388
599
  try {
389
600
  fn();
390
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++) {
391
608
  try {
392
609
  unlinkSync2(lock);
393
- } catch {
610
+ return;
611
+ } catch (err) {
612
+ const code = err.code ?? "";
613
+ if (code === "ENOENT" || !BUSY.has(code)) return;
614
+ sleep(5 + attempt * 5);
394
615
  }
395
616
  }
396
617
  }
397
618
  function lockIsStale(lock) {
398
619
  try {
399
- const [pid, at] = readFileSync2(lock, "utf8").trim().split(/\s+/).map(Number);
620
+ const [pid, at] = readFileSync4(lock, "utf8").trim().split(/\s+/).map(Number);
400
621
  if (at && Date.now() - at > LOCK_STALE_MS) return true;
401
622
  if (pid && pid !== process.pid) {
402
623
  try {
@@ -416,7 +637,7 @@ function sleep(ms) {
416
637
  }
417
638
  function readManifestQuietly(path) {
418
639
  try {
419
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
640
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
420
641
  if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
421
642
  return parsed;
422
643
  } catch {
@@ -426,9 +647,9 @@ function readManifestQuietly(path) {
426
647
  function isModified(projectRoot, relPath, m) {
427
648
  const recorded = m.files[relPath];
428
649
  if (!recorded) return false;
429
- const abs = join4(projectRoot, relPath);
430
- if (!existsSync3(abs)) return false;
431
- 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;
432
653
  }
433
654
 
434
655
  // src/template/render.ts
@@ -455,97 +676,16 @@ function renderCommandTable(metadata2) {
455
676
  return ["| Command | Description | Status |", "| --- | --- | --- |", ...rows].join("\n");
456
677
  }
457
678
 
458
- // src/install/vendor.ts
459
- import { posix as posix2 } from "path";
460
- var { join: join5 } = posix2;
461
- var DELIMITERS = {
462
- html: ["<!--", "-->"],
463
- css: ["/*", "*/"]
464
- };
465
- function licencePathFor(relPath) {
466
- const depth = relPath.split("/").length - 1;
467
- return depth === 0 ? "." : Array(depth).fill("..").join("/");
468
- }
469
- function vendorHeader(file, version2, style = "html", licencePath = ".") {
470
- const [open, close] = DELIMITERS[style];
471
- 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")}.`;
472
- return [
473
- `${open} ${file} \u2014 vendored from Jig v${version2}.`,
474
- attribution,
475
- ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
476
- "",
477
- ""
478
- ].join("\n");
479
- }
480
- function findMarkerRegions(existing) {
481
- const regions = [];
482
- let searchFrom = 0;
483
- let openStart = null;
484
- while (true) {
485
- const nextStart = existing.indexOf(BLOCK_START, searchFrom);
486
- const nextEnd = existing.indexOf(BLOCK_END, searchFrom);
487
- if (nextStart === -1 && nextEnd === -1) break;
488
- const endIsNext = nextEnd !== -1 && (nextStart === -1 || nextEnd < nextStart);
489
- if (endIsNext) {
490
- if (openStart !== null) {
491
- regions.push({ start: openStart, end: nextEnd + BLOCK_END.length });
492
- openStart = null;
493
- } else {
494
- regions.push({ start: nextEnd, end: nextEnd + BLOCK_END.length });
495
- }
496
- searchFrom = nextEnd + BLOCK_END.length;
497
- } else {
498
- if (openStart !== null) {
499
- regions.push({ start: openStart, end: openStart + BLOCK_START.length });
500
- }
501
- openStart = nextStart;
502
- searchFrom = nextStart + BLOCK_START.length;
503
- }
504
- }
505
- if (openStart !== null) {
506
- regions.push({ start: openStart, end: existing.length });
507
- }
508
- regions.sort((a, b) => a.start - b.start);
509
- return regions;
510
- }
511
- function stripMarkerRegions(existing) {
512
- const regions = findMarkerRegions(existing);
513
- if (regions.length === 0) {
514
- return { text: existing, insertAt: existing.length, removed: false };
515
- }
516
- let text = "";
517
- let cursor = 0;
518
- let insertAt = -1;
519
- regions.forEach((region, i) => {
520
- let chunk = existing.slice(cursor, region.start);
521
- if (i > 0) chunk = chunk.replace(/^\n/, "");
522
- text += chunk;
523
- if (insertAt === -1) insertAt = text.length;
524
- cursor = region.end;
525
- });
526
- const tail = existing.slice(cursor).replace(/^\n/, "");
527
- text += tail;
528
- return { text, insertAt, removed: true };
529
- }
530
- function upsertBlock(existing, block) {
531
- const { text, insertAt, removed } = stripMarkerRegions(existing);
532
- if (!removed) {
533
- const separator = text.length && !text.endsWith("\n") ? "\n\n" : text.length ? "\n" : "";
534
- return `${text}${separator}${block}`;
535
- }
536
- return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
537
- }
538
-
539
679
  // src/install/writer.ts
540
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
541
- 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";
542
682
 
543
683
  // src/install/line-endings.ts
544
684
  function dominantEnding(existing) {
545
685
  const crlf = (existing.match(/\r\n/g) ?? []).length;
546
- const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
547
- if (crlf === 0 && lf === 0) return "\n";
548
- 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";
549
689
  }
550
690
  function matchLineEndings(content, existing) {
551
691
  const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -562,9 +702,9 @@ function createWriter(root, seed = {}) {
562
702
  return {
563
703
  files,
564
704
  write(key, content) {
565
- const abs = join6(root, ...key.split("/"));
705
+ const abs = join7(root, ...key.split("/"));
566
706
  mkdirSync3(dirname4(abs), { recursive: true });
567
- const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
707
+ const existing = existsSync5(abs) ? readFileSync5(abs, "utf8") : void 0;
568
708
  const toWrite = matchLineEndings(content, existing);
569
709
  writeFileAtomic(abs, toWrite);
570
710
  files[key] = checksum(toWrite);
@@ -574,15 +714,15 @@ function createWriter(root, seed = {}) {
574
714
  }
575
715
  function bundleFiles(packageRoot2, dir) {
576
716
  const ext = dir === "rules" ? ".md" : ".css";
577
- return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
717
+ return readdirSync2(join7(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
578
718
  }
579
719
 
580
720
  // src/commands/install.ts
581
721
  var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
582
722
  function buildSkillBody(packageRoot2, rulesPath, version2) {
583
- const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
723
+ const template = readFileSync6(join8(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
584
724
  const metadata2 = JSON.parse(
585
- readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
725
+ readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
586
726
  );
587
727
  return render(template, {
588
728
  command_prefix: "/jig ",
@@ -599,12 +739,12 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
599
739
  }
600
740
  function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
601
741
  const metadata2 = JSON.parse(
602
- readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
742
+ readFileSync6(join8(packageRoot2, "templates", "command-metadata.json"), "utf8")
603
743
  );
604
744
  const subcommands = Object.entries(metadata2).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
605
- const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
606
- if (!existsSync5(templatePath)) return { body: "", subcommands };
607
- 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");
608
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");
609
749
  const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
610
750
  if (unresolved.length > 0) {
@@ -632,6 +772,7 @@ function install(opts) {
632
772
  written: [],
633
773
  skipped: [],
634
774
  stopHook: installStopHook(opts.projectRoot, opts.version),
775
+ agentFiles: ensureAgentFiles(opts.projectRoot, { claude: true }),
635
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.`
636
777
  };
637
778
  }
@@ -652,9 +793,9 @@ function install(opts) {
652
793
  existingManifest = null;
653
794
  }
654
795
  const planned = [];
655
- const rulesDir = join7(opts.packageRoot, "rules");
796
+ const rulesDir = join8(opts.packageRoot, "rules");
656
797
  for (const file of bundleFiles(opts.packageRoot, "rules")) {
657
- const body = readFileSync4(join7(rulesDir, file), "utf8");
798
+ const body = readFileSync6(join8(rulesDir, file), "utf8");
658
799
  planned.push({
659
800
  key: relKey(referenceDir, "rules", file),
660
801
  content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
@@ -670,13 +811,13 @@ function install(opts) {
670
811
  }
671
812
  planned.push({
672
813
  key: relKey(referenceDir, "rules.index.json"),
673
- content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
814
+ content: readFileSync6(join8(opts.packageRoot, "rules.index.json"), "utf8"),
674
815
  checkable: true
675
816
  });
676
817
  for (const src of ["LICENSE", "NOTICE"]) {
677
818
  planned.push({
678
819
  key: relKey(referenceDir, src),
679
- content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
820
+ content: readFileSync6(join8(opts.packageRoot, src), "utf8"),
680
821
  checkable: false
681
822
  });
682
823
  }
@@ -698,9 +839,9 @@ function install(opts) {
698
839
  });
699
840
  for (const file of skillFiles) {
700
841
  const key = file.relPath;
701
- const abs = join7(installRoot, ...key.split("/"));
842
+ const abs = join8(installRoot, ...key.split("/"));
702
843
  const isBlockFile = file.content.includes(BLOCK_START);
703
- 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;
704
845
  planned.push({ key, content, checkable: !isBlockFile });
705
846
  }
706
847
  const writer = createWriter(installRoot);
@@ -727,11 +868,12 @@ function install(opts) {
727
868
  writeManifest(installRoot, manifest, referenceDir);
728
869
  written.push(relKey(referenceDir, "manifest.json"));
729
870
  const stopHook = opts.hook && opts.agent === "claude" && opts.scope === "project" ? installStopHook(installRoot, opts.version) : void 0;
730
- 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 };
731
873
  }
732
874
  function hasStopHook(projectRoot) {
733
875
  try {
734
- const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
876
+ const settings = readFileSync6(join8(projectRoot, ".claude", "settings.json"), "utf8");
735
877
  const hooks = JSON.parse(settings).hooks?.Stop ?? [];
736
878
  return hooks.some((group) => (group.hooks ?? []).some((h) => JIG_GATE_HOOK.test(h.command ?? "")));
737
879
  } catch {
@@ -739,11 +881,11 @@ function hasStopHook(projectRoot) {
739
881
  }
740
882
  }
741
883
  function installStopHook(projectRoot, version2) {
742
- const path = join7(projectRoot, ".claude", "settings.json");
884
+ const path = join8(projectRoot, ".claude", "settings.json");
743
885
  let settings = {};
744
- if (existsSync5(path)) {
886
+ if (existsSync6(path)) {
745
887
  try {
746
- settings = JSON.parse(readFileSync4(path, "utf8"));
888
+ settings = JSON.parse(readFileSync6(path, "utf8"));
747
889
  } catch {
748
890
  return false;
749
891
  }
@@ -754,14 +896,14 @@ function installStopHook(projectRoot, version2) {
754
896
  const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
755
897
  stop.push({ hooks: [{ type: "command", command }] });
756
898
  settings.hooks = { ...hooks, Stop: stop };
757
- mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
899
+ mkdirSync4(join8(projectRoot, ".claude"), { recursive: true });
758
900
  writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
759
901
  return true;
760
902
  }
761
903
 
762
904
  // src/commands/update.ts
763
- import { join as join10 } from "path";
764
- 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";
765
907
 
766
908
  // src/install/target.ts
767
909
  import { resolve } from "path";
@@ -804,14 +946,14 @@ function resolveAllInstalled(projectRoot, homeDir) {
804
946
  }
805
947
 
806
948
  // src/init/state.ts
807
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5 } from "fs";
808
- import { dirname as dirname5, join as join8 } from "path";
809
- 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");
810
952
  function readInitManifest(projectRoot) {
811
- const path = join8(projectRoot, INIT_MANIFEST_REL);
812
- if (!existsSync6(path)) return null;
953
+ const path = join9(projectRoot, INIT_MANIFEST_REL);
954
+ if (!existsSync7(path)) return null;
813
955
  try {
814
- const raw = JSON.parse(readFileSync5(path, "utf8"));
956
+ const raw = JSON.parse(readFileSync7(path, "utf8"));
815
957
  if (typeof raw !== "object" || raw === null) return null;
816
958
  const { files, version: version2, modes } = raw;
817
959
  if (typeof files !== "object" || files === null) return null;
@@ -826,7 +968,7 @@ function readInitManifest(projectRoot) {
826
968
  }
827
969
  var MERGE_ATTEMPTS = 5;
828
970
  function writeInitManifest(projectRoot, m) {
829
- const path = join8(projectRoot, INIT_MANIFEST_REL);
971
+ const path = join9(projectRoot, INIT_MANIFEST_REL);
830
972
  mkdirSync5(dirname5(path), { recursive: true });
831
973
  for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
832
974
  const onDisk = readInitManifest(projectRoot);
@@ -847,15 +989,15 @@ function isInitFileModified(projectRoot, relPath, m) {
847
989
  }
848
990
 
849
991
  // src/init/migrate.ts
850
- import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
851
- 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";
852
994
  var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
853
995
  function isLegacyInstallArtifact(name) {
854
996
  return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
855
997
  }
856
998
  function readLegacyManifest(dirAbs) {
857
999
  try {
858
- const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
1000
+ const raw = JSON.parse(readFileSync8(join10(dirAbs, "manifest.json"), "utf8"));
859
1001
  if (raw && typeof raw === "object" && typeof raw.files === "object") {
860
1002
  return raw;
861
1003
  }
@@ -867,8 +1009,8 @@ function classify(projectRoot, relPath, legacyManifest) {
867
1009
  return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
868
1010
  }
869
1011
  function detectLegacyRules(projectRoot) {
870
- const jigDir = join9(projectRoot, ".jig");
871
- if (!existsSync7(jigDir)) return { present: false, files: [] };
1012
+ const jigDir = join10(projectRoot, ".jig");
1013
+ if (!existsSync8(jigDir)) return { present: false, files: [] };
872
1014
  const legacyManifest = readLegacyManifest(jigDir);
873
1015
  let entries;
874
1016
  try {
@@ -884,12 +1026,12 @@ function detectLegacyRules(projectRoot) {
884
1026
  }
885
1027
  function detectLegacyCursorRules(projectRoot) {
886
1028
  const bundleDirRel = ".cursor/rules/jig";
887
- const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
1029
+ const bundleDir = join10(projectRoot, ...bundleDirRel.split("/"));
888
1030
  const mdcRel = ".cursor/rules/jig.mdc";
889
- const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
1031
+ const mdcAbs = join10(projectRoot, ...mdcRel.split("/"));
890
1032
  const legacyManifest = readLegacyManifest(bundleDir);
891
1033
  const files = [];
892
- if (existsSync7(mdcAbs)) {
1034
+ if (existsSync8(mdcAbs)) {
893
1035
  files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
894
1036
  }
895
1037
  let entries;
@@ -943,7 +1085,7 @@ function removableLegacyFiles(report2) {
943
1085
  function removeLegacyFiles(projectRoot, relPaths) {
944
1086
  for (const relPath of relPaths) {
945
1087
  try {
946
- rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
1088
+ rmSync(join10(projectRoot, ...relPath.split("/")), { force: true });
947
1089
  } catch {
948
1090
  }
949
1091
  }
@@ -986,6 +1128,9 @@ function update(opts) {
986
1128
  const initResult = updateInitFiles(opts);
987
1129
  updated.push(...initResult.updated);
988
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
+ }
989
1134
  return {
990
1135
  updated,
991
1136
  skipped,
@@ -1008,14 +1153,14 @@ function updateTarget(opts, resolved) {
1008
1153
  const write = (key, content) => {
1009
1154
  updated.push(writer.write(key, content));
1010
1155
  };
1011
- const rulesDir = join10(opts.packageRoot, "rules");
1156
+ const rulesDir = join11(opts.packageRoot, "rules");
1012
1157
  for (const file of bundleFiles(opts.packageRoot, "rules")) {
1013
1158
  const key = relKey(referenceDir, "rules", file);
1014
1159
  if (isModified(installRoot, key, existing)) {
1015
1160
  skipped.push(key);
1016
1161
  continue;
1017
1162
  }
1018
- 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"));
1019
1164
  }
1020
1165
  for (const ref of referenceFiles(opts.packageRoot)) {
1021
1166
  const key = relKey(referenceDir, ...ref.relPath.split("/"));
@@ -1029,7 +1174,7 @@ function updateTarget(opts, resolved) {
1029
1174
  if (isModified(installRoot, indexKey, existing)) {
1030
1175
  skipped.push(indexKey);
1031
1176
  } else {
1032
- write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
1177
+ write(indexKey, readFileSync9(join11(opts.packageRoot, "rules.index.json"), "utf8"));
1033
1178
  }
1034
1179
  const rulesPath = rulesPathFor(referenceDir, discoveredScope);
1035
1180
  const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
@@ -1051,8 +1196,8 @@ function updateTarget(opts, resolved) {
1051
1196
  const key = file.relPath;
1052
1197
  const isBlockFile = file.content.includes(BLOCK_START);
1053
1198
  if (isBlockFile) {
1054
- const abs = join10(installRoot, ...key.split("/"));
1055
- const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
1199
+ const abs = join11(installRoot, ...key.split("/"));
1200
+ const current = existsSync9(abs) ? readFileSync9(abs, "utf8") : "";
1056
1201
  write(key, upsertBlock(current, file.content));
1057
1202
  continue;
1058
1203
  }
@@ -1063,7 +1208,7 @@ function updateTarget(opts, resolved) {
1063
1208
  write(key, file.content);
1064
1209
  }
1065
1210
  for (const src of ["LICENSE", "NOTICE"]) {
1066
- write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
1211
+ write(relKey(referenceDir, src), readFileSync9(join11(opts.packageRoot, src), "utf8"));
1067
1212
  }
1068
1213
  const manifest = {
1069
1214
  ...existing,
@@ -1079,7 +1224,7 @@ function updateInitFiles(opts) {
1079
1224
  const updated = [];
1080
1225
  const skipped = [];
1081
1226
  const tokens = [];
1082
- const tokensDir = join10(opts.packageRoot, "tokens");
1227
+ const tokensDir = join11(opts.packageRoot, "tokens");
1083
1228
  const initManifest = readInitManifest(opts.projectRoot);
1084
1229
  if (initManifest) {
1085
1230
  const initFiles = { ...initManifest.files };
@@ -1091,10 +1236,10 @@ function updateInitFiles(opts) {
1091
1236
  for (const key of keys) {
1092
1237
  let before = "";
1093
1238
  try {
1094
- before = readFileSync7(join10(opts.projectRoot, ...key.split("/")), "utf8");
1239
+ before = readFileSync9(join11(opts.projectRoot, ...key.split("/")), "utf8");
1095
1240
  } catch {
1096
1241
  }
1097
- const fresh = readFileSync7(join10(tokensDir, file), "utf8");
1242
+ const fresh = readFileSync9(join11(tokensDir, file), "utf8");
1098
1243
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
1099
1244
  skipped.push(key);
1100
1245
  const change2 = tokenChange(key, before, fresh, true);
@@ -1115,12 +1260,12 @@ function updateInitFiles(opts) {
1115
1260
  }
1116
1261
 
1117
1262
  // src/commands/explain.ts
1118
- import { existsSync as existsSync9 } from "fs";
1119
- import { join as join15 } from "path";
1263
+ import { existsSync as existsSync10 } from "fs";
1264
+ import { join as join16 } from "path";
1120
1265
 
1121
1266
  // src/rules/load.ts
1122
- import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
1123
- import { join as join11 } from "path";
1267
+ import { readFileSync as readFileSync10, readdirSync as readdirSync4 } from "fs";
1268
+ import { join as join12 } from "path";
1124
1269
 
1125
1270
  // src/rules/parse.ts
1126
1271
  var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
@@ -1216,9 +1361,9 @@ function loadRules(rulesDir, indexPath) {
1216
1361
  const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
1217
1362
  const parsed = [];
1218
1363
  for (const file of files) {
1219
- parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
1364
+ parsed.push(...parseRules(readFileSync10(join12(rulesDir, file), "utf8"), file));
1220
1365
  }
1221
- const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
1366
+ const entries = validateIndex(JSON.parse(readFileSync10(indexPath, "utf8")));
1222
1367
  const byId = new Map(entries.map((e) => [e.id, e]));
1223
1368
  const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1224
1369
  if (missing.length) {
@@ -1240,8 +1385,8 @@ function loadRules(rulesDir, indexPath) {
1240
1385
  }
1241
1386
 
1242
1387
  // src/rules/specs.ts
1243
- import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
1244
- import { join as join12 } from "path";
1388
+ import { readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
1389
+ import { join as join13 } from "path";
1245
1390
  var KINDS = {
1246
1391
  P: "pattern",
1247
1392
  M: "mode",
@@ -1300,14 +1445,14 @@ function parseSpecs(markdown, sourceFile) {
1300
1445
  function loadSpecs(rulesDir) {
1301
1446
  const specs = [];
1302
1447
  for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1303
- specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
1448
+ specs.push(...parseSpecs(readFileSync11(join13(rulesDir, file), "utf8"), file));
1304
1449
  }
1305
1450
  return specs;
1306
1451
  }
1307
1452
 
1308
1453
  // src/rules/sections.ts
1309
- import { readFileSync as readFileSync10, readdirSync as readdirSync6 } from "fs";
1310
- import { join as join13 } from "path";
1454
+ import { readFileSync as readFileSync12, readdirSync as readdirSync6 } from "fs";
1455
+ import { join as join14 } from "path";
1311
1456
  var RULE_HEADING = /^###\s+[A-Z]-\d+\b/;
1312
1457
  var SPEC_HEADING = /^##\s+[PMLRT]-\d+\b/;
1313
1458
  var ANY_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
@@ -1348,16 +1493,16 @@ function parseSections(markdown, file) {
1348
1493
  function loadSections(rulesDir) {
1349
1494
  const out = [];
1350
1495
  for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1351
- out.push(...parseSections(readFileSync10(join13(rulesDir, file), "utf8"), file));
1496
+ out.push(...parseSections(readFileSync12(join14(rulesDir, file), "utf8"), file));
1352
1497
  }
1353
1498
  return out;
1354
1499
  }
1355
1500
 
1356
1501
  // src/rules/layers.ts
1357
- import { readFileSync as readFileSync11 } from "fs";
1358
- import { join as join14 } from "path";
1502
+ import { readFileSync as readFileSync13 } from "fs";
1503
+ import { join as join15 } from "path";
1359
1504
  function loadLayers(packageRoot2) {
1360
- return JSON.parse(readFileSync11(join14(packageRoot2, "layers.json"), "utf8"));
1505
+ return JSON.parse(readFileSync13(join15(packageRoot2, "layers.json"), "utf8"));
1361
1506
  }
1362
1507
 
1363
1508
  // src/commands/explain.ts
@@ -1439,8 +1584,8 @@ function renderSection(s) {
1439
1584
  function explain(opts) {
1440
1585
  const raw = opts.ruleId.trim().toUpperCase();
1441
1586
  const root = opts.packageRoot ?? assetRoot();
1442
- const rulesDir = join15(root, "rules");
1443
- 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"));
1444
1589
  const specs = loadSpecs(rulesDir);
1445
1590
  const sections = loadSections(rulesDir);
1446
1591
  const entries = searchEntries(rules, specs, sections);
@@ -1496,8 +1641,8 @@ function explain(opts) {
1496
1641
  const id = `${shape[1]}-${shape[2]}`;
1497
1642
  const rule = rules.find((r) => r.id === id);
1498
1643
  if (rule) {
1499
- const example = join15(root, "examples", `${id}.html`);
1500
- return existsSync9(example) ? `${renderRule(rule)}
1644
+ const example = join16(root, "examples", `${id}.html`);
1645
+ return existsSync10(example) ? `${renderRule(rule)}
1501
1646
 
1502
1647
  Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : renderRule(rule);
1503
1648
  }
@@ -1522,8 +1667,9 @@ Example: ${example} (a dont and a do, each a self-contained HTML fragment)` : re
1522
1667
  const idBody = ids.filter((e) => !idTitle.includes(e) && matches(e.text, needle));
1523
1668
  const secTitle = secs.filter((e) => matches(e.title, needle));
1524
1669
  const secBody = secs.filter((e) => !secTitle.includes(e) && matches(e.text, needle));
1670
+ const opens = (e) => e.title.toLowerCase().startsWith(needle.toLowerCase()) ? 0 : 1;
1525
1671
  const hits = [
1526
- ...idTitle.sort((a, b) => byNumber(a.id, b.id)),
1672
+ ...idTitle.sort((a, b) => opens(a) - opens(b) || byNumber(a.id, b.id)),
1527
1673
  ...idBody.sort((a, b) => byNumber(a.id, b.id)),
1528
1674
  ...secTitle,
1529
1675
  ...secBody
@@ -1553,13 +1699,13 @@ function sectionsOf(entries) {
1553
1699
  }
1554
1700
 
1555
1701
  // src/commands/check.ts
1556
- import { existsSync as existsSync15, readFileSync as readFileSync19 } from "fs";
1557
- import { join as join24 } from "path";
1702
+ import { existsSync as existsSync16, readFileSync as readFileSync21 } from "fs";
1703
+ import { join as join25 } from "path";
1558
1704
 
1559
1705
  // src/check/files.ts
1560
1706
  import { execFileSync } from "child_process";
1561
1707
  import { readdirSync as readdirSync7, realpathSync, statSync as statSync2 } from "fs";
1562
- import { join as join16, relative, sep } from "path";
1708
+ import { join as join17, relative, sep } from "path";
1563
1709
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1564
1710
  "node_modules",
1565
1711
  "dist",
@@ -1634,7 +1780,7 @@ function walk(root, dir, out, seen) {
1634
1780
  }
1635
1781
  for (const entry of entries) {
1636
1782
  if (EXCLUDE_DIRS.has(entry.name)) continue;
1637
- const abs = join16(dir, entry.name);
1783
+ const abs = join17(dir, entry.name);
1638
1784
  let isDir = entry.isDirectory();
1639
1785
  let isFile = entry.isFile();
1640
1786
  if (entry.isSymbolicLink()) {
@@ -1776,6 +1922,10 @@ function formatReport(findings, meta) {
1776
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.`
1777
1923
  );
1778
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
+ }
1779
1929
  if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
1780
1930
  const n = meta.exempt?.length ?? 0;
1781
1931
  lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
@@ -1783,6 +1933,9 @@ function formatReport(findings, meta) {
1783
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" : ""}`;
1784
1934
  lines.push(` ${pattern} (${note})`);
1785
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
+ }
1786
1939
  if (n > 0) {
1787
1940
  lines.push(` ${meta.exempt.slice(0, 8).join(", ")}${n > 8 ? `, and ${n - 8} more` : ""}`);
1788
1941
  }
@@ -1946,8 +2099,8 @@ function isReaderText(file) {
1946
2099
  }
1947
2100
 
1948
2101
  // src/check/run.ts
1949
- import { readFileSync as readFileSync13 } from "fs";
1950
- import { join as join18 } from "path";
2102
+ import { readFileSync as readFileSync15 } from "fs";
2103
+ import { join as join19 } from "path";
1951
2104
 
1952
2105
  // src/check/css.ts
1953
2106
  function maskComments(source) {
@@ -3366,8 +3519,8 @@ var menuState = {
3366
3519
  };
3367
3520
 
3368
3521
  // src/check/declared-properties.ts
3369
- import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
3370
- import { join as join17 } from "path";
3522
+ import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
3523
+ import { join as join18 } from "path";
3371
3524
  var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
3372
3525
  var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
3373
3526
  var SET_PROPERTY_RE = /setProperty\(\s*['"`](--[\w-]+)/g;
@@ -3379,16 +3532,16 @@ function addAll(names, text) {
3379
3532
  }
3380
3533
  }
3381
3534
  function readPackageImport(projectRoot, spec) {
3382
- const base = join17(projectRoot, "node_modules", spec);
3535
+ const base = join18(projectRoot, "node_modules", spec);
3383
3536
  for (const candidate of [base, `${base}.css`]) {
3384
3537
  try {
3385
- if (existsSync10(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
3538
+ if (existsSync11(candidate) && candidate.endsWith(".css")) return readFileSync14(candidate, "utf8");
3386
3539
  } catch {
3387
3540
  }
3388
3541
  }
3389
3542
  try {
3390
- const pkg = JSON.parse(readFileSync12(join17(base, "package.json"), "utf8"));
3391
- 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");
3392
3545
  } catch {
3393
3546
  }
3394
3547
  return void 0;
@@ -3400,7 +3553,7 @@ function collectDeclaredProperties(projectRoot, files, tokenNames = []) {
3400
3553
  for (const file of files) {
3401
3554
  let raw;
3402
3555
  try {
3403
- raw = readFileSync12(join17(projectRoot, file), "utf8");
3556
+ raw = readFileSync14(join18(projectRoot, file), "utf8");
3404
3557
  } catch {
3405
3558
  continue;
3406
3559
  }
@@ -3505,19 +3658,21 @@ function indentedProse(source) {
3505
3658
  const blank3 = (m) => " ".repeat(m.length);
3506
3659
  return source.split("\n").map((line) => /^\s*(-|\/\/|#(?!\w)|=)/.test(line) ? blank3(line) : line).join("\n");
3507
3660
  }
3661
+ function readerSpans(file, raw) {
3662
+ const markdown = MARKDOWN.test(file);
3663
+ const indented = INDENTED.test(file);
3664
+ const masked = markdown ? markdownProse(raw) : indented ? indentedProse(maskNonProse(raw)) : maskNonProse(raw);
3665
+ const script = SCRIPT.test(file);
3666
+ return markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
3667
+ }
3508
3668
  var emDash = {
3509
3669
  name: "em-dash",
3510
3670
  appliesTo: (file) => isReaderText(file),
3511
3671
  run(_source, file, ctx) {
3512
- const markdown = MARKDOWN.test(file);
3513
- const indented = INDENTED.test(file);
3514
- const masked = markdown ? markdownProse(ctx.raw) : indented ? indentedProse(maskNonProse(ctx.raw)) : maskNonProse(ctx.raw);
3515
3672
  const starts = buildLineIndex(ctx.raw);
3516
3673
  const findings = [];
3517
3674
  const seen = /* @__PURE__ */ new Set();
3518
- const script = SCRIPT.test(file);
3519
- const spans = markdown || indented ? [{ index: 0, text: masked }] : proseSpans(masked, !script).filter((span) => !script || !CODEY.test(span.text));
3520
- for (const span of spans) {
3675
+ for (const span of readerSpans(file, ctx.raw)) {
3521
3676
  if (!EM_DASH.test(span.text)) continue;
3522
3677
  for (const hit of span.text.matchAll(/\u2014/g)) {
3523
3678
  const line = lineForOffset(starts, span.index + hit.index);
@@ -3539,6 +3694,135 @@ var emDash = {
3539
3694
  }
3540
3695
  };
3541
3696
 
3697
+ // src/check/detectors/prose.ts
3698
+ var FORMULA = new RegExp(
3699
+ [
3700
+ String.raw`in today['’]s (?:fast[- ]paced|rapidly|ever[- ]|increasingly|digital|modern|busy)`,
3701
+ String.raw`in an (?:increasingly|ever[- ](?:changing|evolving)) \w+ (?:world|landscape|age|marketplace|era)`,
3702
+ String.raw`as technology continues to evolve`,
3703
+ String.raw`it(?: is|['’]s) (?:important|worth) (?:to note|noting)`,
3704
+ String.raw`let['’]s (?:dive|delve)\b`,
3705
+ String.raw`here['’]s the thing\b`,
3706
+ String.raw`without further ado`,
3707
+ String.raw`in conclusion\b`,
3708
+ String.raw`only time will tell`,
3709
+ String.raw`the future (?:looks|is) (?:bright|incredibly promising)`
3710
+ ].map((p) => `\\b${p}`).join("|"),
3711
+ "gi"
3712
+ );
3713
+ function maskQuotations(raw) {
3714
+ const blank3 = (m) => m.replace(/[^\n]/g, " ");
3715
+ return raw.replace(/<blockquote\b[\s\S]*?<\/blockquote\s*>/gi, blank3).replace(/<q\b[\s\S]*?<\/q\s*>/gi, blank3).replace(/^ {0,3}>[^\n]*/gm, blank3);
3716
+ }
3717
+ var formulaPhrase = {
3718
+ name: "formula-phrase",
3719
+ appliesTo: (file) => isReaderText(file),
3720
+ run(_source, file, ctx) {
3721
+ const starts = buildLineIndex(ctx.raw);
3722
+ const findings = [];
3723
+ const seen = /* @__PURE__ */ new Set();
3724
+ for (const span of readerSpans(file, maskQuotations(ctx.raw))) {
3725
+ for (const hit of span.text.matchAll(FORMULA)) {
3726
+ const line = lineForOffset(starts, span.index + hit.index);
3727
+ if (seen.has(line)) continue;
3728
+ seen.add(line);
3729
+ findings.push(
3730
+ mkFinding(
3731
+ ctx,
3732
+ "formula-phrase",
3733
+ file,
3734
+ line,
3735
+ `a formula phrase ("${hit[0]}"): start with the point, and end when it has been made`,
3736
+ sourceLine(ctx.raw, line)
3737
+ )
3738
+ );
3739
+ }
3740
+ }
3741
+ return findings.sort((a, b) => a.line - b.line);
3742
+ }
3743
+ };
3744
+ var HEADING3 = /^ {0,3}#{1,6}[ \t]+(.+)$/gm;
3745
+ var FENCE = /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[^\n]*$/gm;
3746
+ var headingEmoji = {
3747
+ name: "heading-emoji",
3748
+ appliesTo: (file) => isReaderText(file) && hasExtension(file, [".md", ".markdown"]) && !isStyleBearing(file),
3749
+ run(_source, file, ctx) {
3750
+ const masked = ctx.raw.replace(FENCE, (m) => m.replace(/[^\n]/g, " "));
3751
+ const starts = buildLineIndex(ctx.raw);
3752
+ const findings = [];
3753
+ for (const heading of masked.matchAll(HEADING3)) {
3754
+ const glyph = [...heading[1].matchAll(EMOJI_RE)].find((m) => !TEXTUAL.has(m[0]));
3755
+ if (!glyph) continue;
3756
+ const line = lineForOffset(starts, heading.index);
3757
+ findings.push(
3758
+ mkFinding(
3759
+ ctx,
3760
+ "heading-emoji",
3761
+ file,
3762
+ line,
3763
+ `an emoji in a heading ("${glyph[0]}"): let the words carry it; a screen reader reads the emoji's name first`,
3764
+ sourceLine(ctx.raw, line)
3765
+ )
3766
+ );
3767
+ }
3768
+ return findings;
3769
+ }
3770
+ };
3771
+
3772
+ // src/check/detectors/layout-motion.ts
3773
+ var LAYOUT = String.raw`(?:(?:min-|max-)?(?:width|height)|(?:inline|block)-size|top|left|right|bottom|inset(?:-[a-z-]+)?|margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?)`;
3774
+ var LAYOUT_NAME = new RegExp(`^${LAYOUT}$`, "i");
3775
+ var TRANSITION = /(?<![-\w])transition(-property)?\s*:\s*([^;}]+)/gi;
3776
+ var KEYFRAMES = /@(?:-webkit-)?keyframes\b[^{]*\{/gi;
3777
+ var KEYFRAME_DECL = new RegExp(`(?<![-\\w])(${LAYOUT})\\s*:`, "gi");
3778
+ var TAILWIND = new RegExp(`(?<![-\\w\\[])transition-(all|\\[[^\\]\\s]*?\\b${LAYOUT}\\b[^\\]\\s]*\\])(?![-\\w])`, "gi");
3779
+ function namedProperties(value, longhand) {
3780
+ return value.split(",").map((item) => {
3781
+ const words = item.trim().split(/\s+/);
3782
+ return longhand ? words[0] : words.find((w) => /^[a-z-]+$/i.test(w) && !/^(ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end|allow-discrete|normal|none)$/i.test(w)) ?? "";
3783
+ }).filter(Boolean);
3784
+ }
3785
+ function blockEnd(source, open) {
3786
+ let depth = 0;
3787
+ for (let i = open; i < source.length; i++) {
3788
+ if (source[i] === "{") depth++;
3789
+ else if (source[i] === "}" && --depth === 0) return i;
3790
+ }
3791
+ return source.length;
3792
+ }
3793
+ var layoutMotion = {
3794
+ name: "layout-motion",
3795
+ appliesTo: (file) => isStyleBearing(file),
3796
+ run(source, file, ctx) {
3797
+ const findings = [];
3798
+ const seen = /* @__PURE__ */ new Set();
3799
+ const report2 = (text, offset, message) => {
3800
+ const line = lineForOffset(buildLineIndex(text), offset);
3801
+ if (seen.has(line)) return;
3802
+ seen.add(line);
3803
+ findings.push(mkFinding(ctx, "layout-motion", file, line, message, sourceLine(text, line)));
3804
+ };
3805
+ for (const m of source.matchAll(TRANSITION)) {
3806
+ const names = namedProperties(m[2], Boolean(m[1]));
3807
+ const bad = names.find((n) => n.toLowerCase() === "all" || LAYOUT_NAME.test(n));
3808
+ if (!bad) continue;
3809
+ report2(source, m.index, bad.toLowerCase() === "all" ? "a transition on `all`: name the properties it covers, and animate transform and opacity" : `a transition on \`${bad}\`, which lays the page out again every frame: animate transform or opacity instead`);
3810
+ }
3811
+ for (const k of source.matchAll(KEYFRAMES)) {
3812
+ const open = k.index + k[0].length - 1;
3813
+ const body = source.slice(open, blockEnd(source, open));
3814
+ for (const d of body.matchAll(KEYFRAME_DECL)) {
3815
+ report2(source, open + d.index, `a keyframe animates \`${d[1]}\`, which lays the page out again every frame: animate transform or opacity instead`);
3816
+ }
3817
+ }
3818
+ const raw = maskProseComments(ctx.raw);
3819
+ for (const t of raw.matchAll(TAILWIND)) {
3820
+ report2(ctx.raw, t.index, `\`${t[0]}\` animates ${t[1] === "all" ? "every property that changes" : "layout"}: use \`transition\`, \`transition-opacity\` or \`transition-transform\``);
3821
+ }
3822
+ return findings.sort((a, b) => a.line - b.line);
3823
+ }
3824
+ };
3825
+
3542
3826
  // src/check/detectors/semantic-element.ts
3543
3827
  var BODY = /<body\b/i;
3544
3828
  var MAIN = /<main\b|role\s*=\s*["']main["']/i;
@@ -4094,6 +4378,9 @@ var DETECTORS = [
4094
4378
  menuState,
4095
4379
  undeclaredToken,
4096
4380
  emDash,
4381
+ formulaPhrase,
4382
+ headingEmoji,
4383
+ layoutMotion,
4097
4384
  semanticElement,
4098
4385
  metadata,
4099
4386
  interfaceSafety,
@@ -4116,7 +4403,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
4116
4403
  if (applicable.length === 0) continue;
4117
4404
  let raw;
4118
4405
  try {
4119
- raw = readFileSync13(join18(projectRoot, file), "utf8");
4406
+ raw = readFileSync15(join19(projectRoot, file), "utf8");
4120
4407
  } catch {
4121
4408
  continue;
4122
4409
  }
@@ -4130,8 +4417,8 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
4130
4417
  }
4131
4418
 
4132
4419
  // src/check/tokens.ts
4133
- import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
4134
- import { join as join19 } from "path";
4420
+ import { existsSync as existsSync12, readdirSync as readdirSync8, readFileSync as readFileSync16 } from "fs";
4421
+ import { join as join20 } from "path";
4135
4422
  function rootDeclarations(source) {
4136
4423
  const out = {};
4137
4424
  for (const block of splitRuleBlocks(source)) {
@@ -4146,20 +4433,20 @@ function rootDeclarations(source) {
4146
4433
  }
4147
4434
  function loadTokenMap(projectRoot, consumerFiles = []) {
4148
4435
  const jig = {};
4149
- const tokensDir = join19(projectRoot, ".jig", "tokens");
4150
- if (existsSync11(tokensDir)) {
4436
+ const tokensDir = join20(projectRoot, ".jig", "tokens");
4437
+ if (existsSync12(tokensDir)) {
4151
4438
  for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
4152
- Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
4439
+ Object.assign(jig, rootDeclarations(readFileSync16(join20(tokensDir, file), "utf8")));
4153
4440
  }
4154
4441
  }
4155
4442
  const consumer = {};
4156
4443
  const ambiguous = /* @__PURE__ */ new Set();
4157
- const vendored = join19(".jig", "tokens");
4444
+ const vendored = join20(".jig", "tokens");
4158
4445
  for (const file of consumerFiles) {
4159
4446
  if (file.includes(vendored)) continue;
4160
4447
  let source;
4161
4448
  try {
4162
- source = readFileSync14(join19(projectRoot, file), "utf8");
4449
+ source = readFileSync16(join20(projectRoot, file), "utf8");
4163
4450
  } catch {
4164
4451
  continue;
4165
4452
  }
@@ -4178,8 +4465,8 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
4178
4465
  }
4179
4466
 
4180
4467
  // src/check/exempt.ts
4181
- import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
4182
- import { join as join20 } from "path";
4468
+ import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
4469
+ import { join as join21 } from "path";
4183
4470
  function globToRegExp(glob) {
4184
4471
  let out = "";
4185
4472
  for (let i = 0; i < glob.length; i++) {
@@ -4198,18 +4485,37 @@ function globToRegExp(glob) {
4198
4485
  return new RegExp(`^${out}$`);
4199
4486
  }
4200
4487
  function readExemptions(projectRoot) {
4201
- const configPath = join20(projectRoot, "jig.config.json");
4202
- if (!existsSync12(configPath)) return [];
4488
+ const none = { patterns: [], ignored: [] };
4489
+ const configPath = join21(projectRoot, "jig.config.json");
4490
+ if (!existsSync13(configPath)) return none;
4203
4491
  let declared;
4204
4492
  try {
4205
- declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
4493
+ declared = JSON.parse(readFileSync17(configPath, "utf8")).exempt;
4206
4494
  } catch {
4207
- return [];
4495
+ return none;
4496
+ }
4497
+ if (!Array.isArray(declared)) return none;
4498
+ const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
4499
+ const patterns = [];
4500
+ const ignored = [];
4501
+ for (const raw of declared) {
4502
+ if (typeof raw !== "string" || !raw.trim()) continue;
4503
+ let p = raw.trim().replace(/\\/g, "/");
4504
+ if (p.startsWith(`${root}/`)) p = p.slice(root.length + 1);
4505
+ p = p.replace(/^(\.\/)+/, "");
4506
+ if (p.startsWith("/") || /^[A-Za-z]:\//.test(p)) {
4507
+ ignored.push({ entry: raw, why: "an absolute path outside this project. Write it from the project folder, where jig.config.json is: `src/\u2026`" });
4508
+ } else if (p.split("/").includes("..")) {
4509
+ ignored.push({ entry: raw, why: "it leaves the project folder (`..`). Write it from the project folder, where jig.config.json is: `src/\u2026`" });
4510
+ } else {
4511
+ patterns.push(p);
4512
+ }
4208
4513
  }
4209
- if (!Array.isArray(declared)) return [];
4210
- return declared.filter(
4211
- (p) => typeof p === "string" && p.trim() !== "" && !p.startsWith("/") && !p.split("/").includes("..")
4212
- );
4514
+ return { patterns, ignored };
4515
+ }
4516
+ function suggestPaths(pattern, files) {
4517
+ if (pattern.includes("/") || /[*?]/.test(pattern)) return [];
4518
+ return files.filter((f) => f === pattern || f.endsWith(`/${pattern}`)).slice(0, 3);
4213
4519
  }
4214
4520
  var LIKELY_TOO_BROAD = 3;
4215
4521
  function applyExemptions(files, patterns) {
@@ -4234,8 +4540,8 @@ function applyExemptions(files, patterns) {
4234
4540
  }
4235
4541
 
4236
4542
  // src/check/waiver.ts
4237
- import { readFileSync as readFileSync16 } from "fs";
4238
- import { join as join21 } from "path";
4543
+ import { readFileSync as readFileSync18 } from "fs";
4544
+ import { join as join22 } from "path";
4239
4545
  var WAIVER = /jig-allow\s+([A-Z]-\d+(?:\s*,\s*[A-Z]-\d+)*)\s*:(.*)$/;
4240
4546
  function readWaiver(line) {
4241
4547
  const m = WAIVER.exec(line);
@@ -4250,7 +4556,7 @@ function applyWaivers(root, findings) {
4250
4556
  let cached = lines.get(file);
4251
4557
  if (!cached) {
4252
4558
  try {
4253
- cached = readFileSync16(join21(root, file), "utf8").split("\n");
4559
+ cached = readFileSync18(join22(root, file), "utf8").split("\n");
4254
4560
  } catch {
4255
4561
  cached = [];
4256
4562
  }
@@ -4315,8 +4621,8 @@ function hasMenuToggle(raw) {
4315
4621
  }
4316
4622
 
4317
4623
  // src/check/mode-wiring.ts
4318
- import { existsSync as existsSync13, readFileSync as readFileSync17 } from "fs";
4319
- import { dirname as dirname6, join as join22 } from "path";
4624
+ import { existsSync as existsSync14, readFileSync as readFileSync19 } from "fs";
4625
+ import { dirname as dirname6, join as join23 } from "path";
4320
4626
  var MODES = ["editorial", "product", "operator"];
4321
4627
  function configProblems(config) {
4322
4628
  const problems = [];
@@ -4338,10 +4644,10 @@ function configProblems(config) {
4338
4644
  }
4339
4645
  function modeWiringProblems(projectRoot) {
4340
4646
  let config;
4341
- const path = join22(projectRoot, "jig.config.json");
4342
- if (!existsSync13(path)) return [];
4647
+ const path = join23(projectRoot, "jig.config.json");
4648
+ if (!existsSync14(path)) return [];
4343
4649
  try {
4344
- config = JSON.parse(readFileSync17(path, "utf8"));
4650
+ config = JSON.parse(readFileSync19(path, "utf8"));
4345
4651
  } catch {
4346
4652
  return [{ mode: "", barrel: "jig.config.json", message: "jig.config.json is not valid JSON, so nothing in it applies" }];
4347
4653
  }
@@ -4354,10 +4660,10 @@ function modeWiringProblems(projectRoot) {
4354
4660
  const rel = (f) => dir === "." ? f : `${dir}/${f}`;
4355
4661
  const multi = modes.length > 1;
4356
4662
  const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
4357
- const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync13(join22(projectRoot, rel(f))));
4663
+ const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync14(join23(projectRoot, rel(f))));
4358
4664
  if (!anyBarrel) return [];
4359
4665
  const problems = [];
4360
- if (multi && existsSync13(join22(projectRoot, rel("theme.css")))) {
4666
+ if (multi && existsSync14(join23(projectRoot, rel("theme.css")))) {
4361
4667
  problems.push({
4362
4668
  mode: modes[0],
4363
4669
  barrel: rel("theme.css"),
@@ -4369,7 +4675,7 @@ function modeWiringProblems(projectRoot) {
4369
4675
  const modeFile = `mode.${mode}.css`;
4370
4676
  let body;
4371
4677
  try {
4372
- body = readFileSync17(join22(projectRoot, barrel), "utf8");
4678
+ body = readFileSync19(join23(projectRoot, barrel), "utf8");
4373
4679
  } catch {
4374
4680
  problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${barrel} does not exist` });
4375
4681
  return;
@@ -4381,7 +4687,7 @@ function modeWiringProblems(projectRoot) {
4381
4687
  barrel,
4382
4688
  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`
4383
4689
  });
4384
- } else if (!existsSync13(join22(projectRoot, rel(modeFile)))) {
4690
+ } else if (!existsSync14(join23(projectRoot, rel(modeFile)))) {
4385
4691
  problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
4386
4692
  }
4387
4693
  });
@@ -4389,8 +4695,8 @@ function modeWiringProblems(projectRoot) {
4389
4695
  }
4390
4696
 
4391
4697
  // src/check/token-audit.ts
4392
- import { existsSync as existsSync14, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
4393
- import { join as join23 } from "path";
4698
+ import { existsSync as existsSync15, readdirSync as readdirSync9, readFileSync as readFileSync20 } from "fs";
4699
+ import { join as join24 } from "path";
4394
4700
  var TEXT_FLOOR = 4.5;
4395
4701
  var UI_FLOOR = 3;
4396
4702
  var FOREGROUNDS = [
@@ -4446,24 +4752,24 @@ function floorFor(token) {
4446
4752
  function tokenFiles(projectRoot) {
4447
4753
  const read = (rel) => {
4448
4754
  try {
4449
- return readFileSync18(join23(projectRoot, ...rel.split("/")), "utf8");
4755
+ return readFileSync20(join24(projectRoot, ...rel.split("/")), "utf8");
4450
4756
  } catch {
4451
4757
  return null;
4452
4758
  }
4453
4759
  };
4454
- const statePath = join23(projectRoot, ".jig", "state.json");
4455
- if (existsSync14(statePath)) {
4760
+ const statePath = join24(projectRoot, ".jig", "state.json");
4761
+ if (existsSync15(statePath)) {
4456
4762
  try {
4457
- const state = JSON.parse(readFileSync18(statePath, "utf8"));
4763
+ const state = JSON.parse(readFileSync20(statePath, "utf8"));
4458
4764
  const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
4459
4765
  const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
4460
4766
  if (out.length > 0) return out;
4461
4767
  } catch {
4462
4768
  }
4463
4769
  }
4464
- const legacy = join23(projectRoot, ".jig", "tokens");
4465
- if (!existsSync14(legacy)) return [];
4466
- return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync18(join23(legacy, f), "utf8") }));
4770
+ const legacy = join24(projectRoot, ".jig", "tokens");
4771
+ if (!existsSync15(legacy)) return [];
4772
+ return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync20(join24(legacy, f), "utf8") }));
4467
4773
  }
4468
4774
  function auditTokenLayer(projectRoot) {
4469
4775
  const files = tokenFiles(projectRoot);
@@ -4531,15 +4837,15 @@ function auditTokenLayer(projectRoot) {
4531
4837
  }
4532
4838
 
4533
4839
  // src/commands/check.ts
4534
- var LEGACY_INDEX_REL = join24(".jig", "rules.index.json");
4840
+ var LEGACY_INDEX_REL = join25(".jig", "rules.index.json");
4535
4841
  function resolveIndexPath(projectRoot) {
4536
- const legacy = join24(projectRoot, LEGACY_INDEX_REL);
4537
- if (existsSync15(legacy)) return legacy;
4538
- return join24(assetRoot(), "rules.index.json");
4842
+ const legacy = join25(projectRoot, LEGACY_INDEX_REL);
4843
+ if (existsSync16(legacy)) return legacy;
4844
+ return join25(assetRoot(), "rules.index.json");
4539
4845
  }
4540
4846
  function resolveMode(projectRoot) {
4541
4847
  try {
4542
- const config = JSON.parse(readFileSync19(join24(projectRoot, "jig.config.json"), "utf8"));
4848
+ const config = JSON.parse(readFileSync21(join25(projectRoot, "jig.config.json"), "utf8"));
4543
4849
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
4544
4850
  if (modes.length === 1) return modes[0];
4545
4851
  return modes.length > 1 ? "mixed" : "unknown";
@@ -4556,24 +4862,25 @@ function summariseUnscanned(files) {
4556
4862
  }
4557
4863
  function countSpecs() {
4558
4864
  try {
4559
- return loadSpecs(join24(assetRoot(), "rules")).length;
4865
+ return loadSpecs(join25(assetRoot(), "rules")).length;
4560
4866
  } catch {
4561
4867
  return 0;
4562
4868
  }
4563
4869
  }
4564
4870
  function check(opts) {
4565
4871
  const indexPath = resolveIndexPath(opts.projectRoot);
4566
- const index = validateIndex(JSON.parse(readFileSync19(indexPath, "utf8")));
4872
+ const index = validateIndex(JSON.parse(readFileSync21(indexPath, "utf8")));
4567
4873
  const selection = selectFiles(opts.projectRoot, opts.all);
4568
- const exemptions = readExemptions(opts.projectRoot);
4569
- const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
4874
+ const { patterns: exemptions, ignored: exemptIgnored } = readExemptions(opts.projectRoot);
4875
+ const { scanned: files, exempt, byPattern: counted } = applyExemptions(selection.files, exemptions);
4876
+ const byPattern = counted.map((p) => ({ ...p, suggest: p.count === 0 && selection.mode !== "changed" ? suggestPaths(p.pattern, selection.files) : [] }));
4570
4877
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
4571
4878
  const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
4572
4879
  const tokens = loadTokenMap(opts.projectRoot, stylesheets);
4573
4880
  const projectParticipates = stylesheets.some((f) => {
4574
4881
  if (!hasExtension(f, CSS_EXTENSIONS)) return false;
4575
4882
  try {
4576
- return participatesInTokenLayer(readFileSync19(join24(opts.projectRoot, f), "utf8"), tokens);
4883
+ return participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
4577
4884
  } catch {
4578
4885
  return false;
4579
4886
  }
@@ -4581,7 +4888,7 @@ function check(opts) {
4581
4888
  const projectResponsive = stylesheets.some((f) => {
4582
4889
  if (!isStyleBearing(f)) return false;
4583
4890
  try {
4584
- const raw = readFileSync19(join24(opts.projectRoot, f), "utf8");
4891
+ const raw = readFileSync21(join25(opts.projectRoot, f), "utf8");
4585
4892
  return isResponsive(maskComments(maskNonStyleRegions(raw, f)), raw);
4586
4893
  } catch {
4587
4894
  return false;
@@ -4590,14 +4897,14 @@ function check(opts) {
4590
4897
  const viewportFitCover = stylesheets.some((f) => {
4591
4898
  if (!isStyleBearing(f)) return false;
4592
4899
  try {
4593
- return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync19(join24(opts.projectRoot, f), "utf8"));
4900
+ return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync21(join25(opts.projectRoot, f), "utf8"));
4594
4901
  } catch {
4595
4902
  return false;
4596
4903
  }
4597
4904
  });
4598
4905
  const projectMenuToggle = stylesheets.some((f) => {
4599
4906
  try {
4600
- return hasMenuToggle(readFileSync19(join24(opts.projectRoot, f), "utf8"));
4907
+ return hasMenuToggle(readFileSync21(join25(opts.projectRoot, f), "utf8"));
4601
4908
  } catch {
4602
4909
  return false;
4603
4910
  }
@@ -4605,7 +4912,7 @@ function check(opts) {
4605
4912
  const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
4606
4913
  const withStyles = files.filter((f) => {
4607
4914
  try {
4608
- const src = readFileSync19(join24(opts.projectRoot, f), "utf8");
4915
+ const src = readFileSync21(join25(opts.projectRoot, f), "utf8");
4609
4916
  return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
4610
4917
  } catch {
4611
4918
  return false;
@@ -4638,7 +4945,7 @@ function check(opts) {
4638
4945
  // told to add a token @import it has nowhere to put.
4639
4946
  cssFiles.length > 0 && cssFiles.every((f) => {
4640
4947
  try {
4641
- return !participatesInTokenLayer(readFileSync19(join24(opts.projectRoot, f), "utf8"), tokens);
4948
+ return !participatesInTokenLayer(readFileSync21(join25(opts.projectRoot, f), "utf8"), tokens);
4642
4949
  } catch {
4643
4950
  return true;
4644
4951
  }
@@ -4658,20 +4965,22 @@ function check(opts) {
4658
4965
  withStyles,
4659
4966
  exempt,
4660
4967
  exemptPatterns: byPattern,
4968
+ exemptIgnored,
4661
4969
  scope: selection.mode,
4662
4970
  modeUnwired: modeWiringProblems(opts.projectRoot),
4971
+ agentFiles: agentFileProblems(opts.projectRoot, opts.homeDir || void 0),
4663
4972
  waived
4664
4973
  });
4665
4974
  return { findings, report: report2, hasError, waived };
4666
4975
  }
4667
4976
 
4668
4977
  // src/commands/init.ts
4669
- import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4670
- import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
4978
+ import { existsSync as existsSync17, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync25, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
4979
+ import { dirname as dirname7, isAbsolute, join as join29, resolve as resolve2, sep as sep3 } from "path";
4671
4980
 
4672
4981
  // src/init/detect.ts
4673
- import { readFileSync as readFileSync20 } from "fs";
4674
- import { join as join25 } from "path";
4982
+ import { readFileSync as readFileSync22 } from "fs";
4983
+ import { join as join26 } from "path";
4675
4984
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
4676
4985
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
4677
4986
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -4688,7 +4997,7 @@ var FRAMEWORK_DEPS = [
4688
4997
  function detectFramework(projectRoot) {
4689
4998
  let pkg;
4690
4999
  try {
4691
- pkg = JSON.parse(readFileSync20(join25(projectRoot, "package.json"), "utf8"));
5000
+ pkg = JSON.parse(readFileSync22(join26(projectRoot, "package.json"), "utf8"));
4692
5001
  } catch {
4693
5002
  return void 0;
4694
5003
  }
@@ -4706,7 +5015,7 @@ function detect(projectRoot) {
4706
5015
  for (const f of cssFiles) {
4707
5016
  let source;
4708
5017
  try {
4709
- source = readFileSync20(join25(projectRoot, f), "utf8");
5018
+ source = readFileSync22(join26(projectRoot, f), "utf8");
4710
5019
  } catch {
4711
5020
  continue;
4712
5021
  }
@@ -4741,8 +5050,8 @@ function isIgnored(projectRoot, relPath) {
4741
5050
  }
4742
5051
 
4743
5052
  // src/init/derive.ts
4744
- import { readFileSync as readFileSync21 } from "fs";
4745
- import { join as join26 } from "path";
5053
+ import { readFileSync as readFileSync23 } from "fs";
5054
+ import { join as join27 } from "path";
4746
5055
  var DEFAULT_PROPOSAL = {
4747
5056
  h: 264,
4748
5057
  s: 0,
@@ -4775,7 +5084,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
4775
5084
  for (const file of cssFiles) {
4776
5085
  let source;
4777
5086
  try {
4778
- source = readFileSync21(join26(projectRoot, file), "utf8");
5087
+ source = readFileSync23(join27(projectRoot, file), "utf8");
4779
5088
  } catch {
4780
5089
  continue;
4781
5090
  }
@@ -4824,7 +5133,7 @@ function balancedBraceBody(source, openIdx) {
4824
5133
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
4825
5134
  let source;
4826
5135
  try {
4827
- source = readFileSync21(join26(projectRoot, tailwindConfigFile), "utf8");
5136
+ source = readFileSync23(join27(projectRoot, tailwindConfigFile), "utf8");
4828
5137
  } catch {
4829
5138
  return null;
4830
5139
  }
@@ -4880,7 +5189,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
4880
5189
  for (const file of cssFiles) {
4881
5190
  let source;
4882
5191
  try {
4883
- source = readFileSync21(join26(projectRoot, file), "utf8");
5192
+ source = readFileSync23(join27(projectRoot, file), "utf8");
4884
5193
  } catch {
4885
5194
  continue;
4886
5195
  }
@@ -5040,8 +5349,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
5040
5349
  }
5041
5350
 
5042
5351
  // src/init/project-name.ts
5043
- import { readFileSync as readFileSync22 } from "fs";
5044
- import { basename, join as join27 } from "path";
5352
+ import { readFileSync as readFileSync24 } from "fs";
5353
+ import { basename, join as join28 } from "path";
5045
5354
  function slugify(name) {
5046
5355
  const withoutScope = name.replace(/^@[^/]+\//, "");
5047
5356
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -5049,7 +5358,7 @@ function slugify(name) {
5049
5358
  }
5050
5359
  function deriveProjectSlug(projectRoot) {
5051
5360
  try {
5052
- const pkg = JSON.parse(readFileSync22(join27(projectRoot, "package.json"), "utf8"));
5361
+ const pkg = JSON.parse(readFileSync24(join28(projectRoot, "package.json"), "utf8"));
5053
5362
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
5054
5363
  } catch {
5055
5364
  }
@@ -5190,7 +5499,7 @@ function parseSurfaces(answer, fallback) {
5190
5499
  return parsed.length > 0 ? parsed : fallback;
5191
5500
  }
5192
5501
  function fileState(projectRoot, absPath, relPath, initManifest) {
5193
- const existsOnDisk = existsSync16(absPath);
5502
+ const existsOnDisk = existsSync17(absPath);
5194
5503
  const tracked = !!initManifest && relPath in initManifest.files;
5195
5504
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
5196
5505
  return { existsOnDisk, tracked, modified };
@@ -5201,11 +5510,11 @@ function isValidSurfaceArray(v) {
5201
5510
  );
5202
5511
  }
5203
5512
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
5204
- if (!existsSync16(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
5513
+ if (!existsSync17(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
5205
5514
  try {
5206
- const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5515
+ const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5207
5516
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
5208
- const brandResolves = brandCandidate ? existsSync16(join28(projectRoot, ...brandCandidate.split("/"))) : false;
5517
+ const brandResolves = brandCandidate ? existsSync17(join29(projectRoot, ...brandCandidate.split("/"))) : false;
5209
5518
  return {
5210
5519
  brand: brandResolves ? brandCandidate : fallbackBrand,
5211
5520
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -5215,10 +5524,10 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
5215
5524
  }
5216
5525
  }
5217
5526
  function configuredBrandPath(projectRoot, configAbsPath) {
5218
- if (!existsSync16(configAbsPath)) return { path: null };
5527
+ if (!existsSync17(configAbsPath)) return { path: null };
5219
5528
  let declared;
5220
5529
  try {
5221
- declared = JSON.parse(readFileSync23(configAbsPath, "utf8")).brand;
5530
+ declared = JSON.parse(readFileSync25(configAbsPath, "utf8")).brand;
5222
5531
  } catch {
5223
5532
  return { path: null };
5224
5533
  }
@@ -5246,9 +5555,9 @@ function defaultTokenDir(detection) {
5246
5555
  return ["jig"];
5247
5556
  }
5248
5557
  function readConfiguredSurfaces(configAbsPath) {
5249
- if (!existsSync16(configAbsPath)) return null;
5558
+ if (!existsSync17(configAbsPath)) return null;
5250
5559
  try {
5251
- const parsed = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5560
+ const parsed = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5252
5561
  return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
5253
5562
  } catch {
5254
5563
  return null;
@@ -5258,8 +5567,8 @@ function isCssModule(path) {
5258
5567
  return /\.module\.(css|scss)$/i.test(path);
5259
5568
  }
5260
5569
  function legacyBrandFile(projectRoot) {
5261
- const dir = join28(projectRoot, ".jig", "tokens");
5262
- if (!existsSync16(dir)) return void 0;
5570
+ const dir = join29(projectRoot, ".jig", "tokens");
5571
+ if (!existsSync17(dir)) return void 0;
5263
5572
  const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
5264
5573
  return found ? relKey(".jig", "tokens", found) : void 0;
5265
5574
  }
@@ -5290,7 +5599,7 @@ var LEGACY_PAIR_RE = new RegExp(
5290
5599
  `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
5291
5600
  );
5292
5601
  function wireBarrel(absPath, barrelImport) {
5293
- const content = existsSync16(absPath) ? readFileSync23(absPath, "utf8") : "";
5602
+ const content = existsSync17(absPath) ? readFileSync25(absPath, "utf8") : "";
5294
5603
  const line = `@import "${barrelImport}";`;
5295
5604
  if (content.includes(line)) return { status: "already-present" };
5296
5605
  const legacy = LEGACY_PAIR_RE.exec(content);
@@ -5385,7 +5694,7 @@ async function init(opts) {
5385
5694
  else if (critiqueAnswer) log(` Could not read '${critiqueAnswer}' \u2014 leaving it page by page; each spec will ask.`);
5386
5695
  }
5387
5696
  if (opts.yes) {
5388
- const declared = readConfiguredSurfaces(join28(opts.projectRoot, "jig.config.json"));
5697
+ const declared = readConfiguredSurfaces(join29(opts.projectRoot, "jig.config.json"));
5389
5698
  const inEffect = declared ?? surfaces;
5390
5699
  const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
5391
5700
  log(
@@ -5404,7 +5713,7 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
5404
5713
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
5405
5714
  const projectSlug = deriveProjectSlug(opts.projectRoot);
5406
5715
  const configRelPath = "jig.config.json";
5407
- const configAbsPath = join28(opts.projectRoot, configRelPath);
5716
+ const configAbsPath = join29(opts.projectRoot, configRelPath);
5408
5717
  const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
5409
5718
  if (configuredBrand.refused) {
5410
5719
  log(
@@ -5438,7 +5747,7 @@ The token layer is at ${currentDir}/, and this project's layout suggests ${would
5438
5747
  }
5439
5748
  }
5440
5749
  const brandRelPath = relocateTo ?? configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
5441
- const brandAbsPath = join28(opts.projectRoot, ...brandRelPath.split("/"));
5750
+ const brandAbsPath = join29(opts.projectRoot, ...brandRelPath.split("/"));
5442
5751
  const tokensRelDir = brandRelPath.split("/").slice(0, -1);
5443
5752
  log(
5444
5753
  `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.")
@@ -5471,7 +5780,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5471
5780
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
5472
5781
  }
5473
5782
  if (brandAction === "written") {
5474
- const defaultCssRaw = readFileSync23(join28(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
5783
+ const defaultCssRaw = readFileSync25(join29(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
5475
5784
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
5476
5785
  mkdirSync6(dirname7(brandAbsPath), { recursive: true });
5477
5786
  writeFileSync4(brandAbsPath, content, "utf8");
@@ -5479,7 +5788,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5479
5788
  }
5480
5789
  if (relocateTo && configAction !== "written") {
5481
5790
  try {
5482
- const existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5791
+ const existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5483
5792
  const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
5484
5793
  `;
5485
5794
  writeFileSync4(configAbsPath, content, "utf8");
@@ -5498,12 +5807,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5498
5807
  } else if (configAction === "merged") {
5499
5808
  let existing = {};
5500
5809
  try {
5501
- existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
5810
+ existing = JSON.parse(readFileSync25(configAbsPath, "utf8"));
5502
5811
  } catch {
5503
5812
  existing = {};
5504
5813
  }
5505
5814
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
5506
- const brandStillResolves = existingBrand ? existsSync16(join28(opts.projectRoot, ...existingBrand.split("/"))) : false;
5815
+ const brandStillResolves = existingBrand ? existsSync17(join29(opts.projectRoot, ...existingBrand.split("/"))) : false;
5507
5816
  const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
5508
5817
  const merged = {
5509
5818
  ...existing,
@@ -5520,12 +5829,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5520
5829
  }
5521
5830
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
5522
5831
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
5523
- const wiringBrandAbsPath = join28(opts.projectRoot, ...effectiveConfig.brand.split("/"));
5832
+ const wiringBrandAbsPath = join29(opts.projectRoot, ...effectiveConfig.brand.split("/"));
5524
5833
  const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
5525
5834
  const modeAbsPaths = {};
5526
5835
  for (const mode of declaredModes) {
5527
5836
  const modeFileName = `mode.${mode}.css`;
5528
- const modeAbsPath2 = join28(opts.projectRoot, ...tokensRelDir, modeFileName);
5837
+ const modeAbsPath2 = join29(opts.projectRoot, ...tokensRelDir, modeFileName);
5529
5838
  modeAbsPaths[mode] = modeAbsPath2;
5530
5839
  const modeRelPath = relKey(...tokensRelDir, modeFileName);
5531
5840
  const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
@@ -5533,7 +5842,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5533
5842
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
5534
5843
  else if (modeState.modified) modeAction = "skipped-edited";
5535
5844
  if (modeAction === "written") {
5536
- const modeSourceRaw = readFileSync23(join28(opts.packageRoot, "tokens", modeFileName), "utf8");
5845
+ const modeSourceRaw = readFileSync25(join29(opts.packageRoot, "tokens", modeFileName), "utf8");
5537
5846
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
5538
5847
  mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
5539
5848
  writeFileSync4(modeAbsPath2, content, "utf8");
@@ -5548,7 +5857,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5548
5857
  const barrelFor = (mode) => multiMode ? `theme.${mode}.css` : "theme.css";
5549
5858
  for (const mode of declaredModes) {
5550
5859
  const rel = relKey(...tokensRelDir, barrelFor(mode));
5551
- const abs = join28(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
5860
+ const abs = join29(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
5552
5861
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5553
5862
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
5554
5863
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
@@ -5561,8 +5870,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5561
5870
  }
5562
5871
  if (multiMode) {
5563
5872
  const rel = relKey(...tokensRelDir, "theme.css");
5564
- const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5565
- if (existsSync16(abs)) {
5873
+ const abs = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
5874
+ if (existsSync17(abs)) {
5566
5875
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5567
5876
  if (state.tracked && !state.modified) {
5568
5877
  rmSync2(abs);
@@ -5577,7 +5886,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5577
5886
  const declared = declaredTokenNames(
5578
5887
  [brandAbsPath, modeAbsPath].map((f) => {
5579
5888
  try {
5580
- return readFileSync23(f, "utf8");
5889
+ return readFileSync25(f, "utf8");
5581
5890
  } catch {
5582
5891
  return "";
5583
5892
  }
@@ -5602,7 +5911,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5602
5911
  log(` This changes how components are written. Both styles are correct.`);
5603
5912
  const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
5604
5913
  if (answer === "y" || answer === "yes") {
5605
- const abs = join28(opts.projectRoot, ...tokensRelDir, "utilities.css");
5914
+ const abs = join29(opts.projectRoot, ...tokensRelDir, "utilities.css");
5606
5915
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5607
5916
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
5608
5917
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
@@ -5613,8 +5922,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5613
5922
  files[rel] = checksum(content);
5614
5923
  const target = findWireTarget(detection);
5615
5924
  const importPath = target ? relativeImportPath(
5616
- dirname7(join28(opts.projectRoot, target)),
5617
- join28(opts.projectRoot, ...tokensRelDir, "utilities.css")
5925
+ dirname7(join29(opts.projectRoot, target)),
5926
+ join29(opts.projectRoot, ...tokensRelDir, "utilities.css")
5618
5927
  ) : `./${rel}`;
5619
5928
  log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
5620
5929
  log(` @import "tailwindcss";`);
@@ -5633,11 +5942,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5633
5942
  writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
5634
5943
  if (relocateTo && currentBrand) {
5635
5944
  const oldDir = currentBrand.split("/").slice(0, -1);
5636
- const oldAbsDir = join28(opts.projectRoot, ...oldDir);
5637
- if (existsSync16(oldAbsDir)) {
5945
+ const oldAbsDir = join29(opts.projectRoot, ...oldDir);
5946
+ if (existsSync17(oldAbsDir)) {
5638
5947
  for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
5639
5948
  const rel = relKey(...oldDir, name);
5640
- const abs = join28(oldAbsDir, name);
5949
+ const abs = join29(oldAbsDir, name);
5641
5950
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
5642
5951
  if (!state.tracked || state.modified) {
5643
5952
  log(` ${rel} was edited \u2014 left in place. Delete it yourself once you have moved anything you need.`);
@@ -5651,11 +5960,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
5651
5960
  const wireTarget = findWireTarget(detection);
5652
5961
  let wiring;
5653
5962
  if (multiMode) {
5654
- const bare2 = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
5963
+ const bare2 = join29(opts.projectRoot, ...tokensRelDir, "theme.css");
5655
5964
  for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
5656
- const abs = join28(opts.projectRoot, file);
5965
+ const abs = join29(opts.projectRoot, file);
5657
5966
  try {
5658
- const before = readFileSync23(abs, "utf8");
5967
+ const before = readFileSync25(abs, "utf8");
5659
5968
  const after = before.split("\n").filter((l) => {
5660
5969
  const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
5661
5970
  return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
@@ -5677,21 +5986,21 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
5677
5986
  snippet: effectiveConfig.surfaces.map((s) => `${s.match} \u2192 ${relKey(...tokensRelDir, barrelFor(s.mode))}`).join("\n")
5678
5987
  };
5679
5988
  } else if (wireTarget) {
5680
- const targetAbsDir = dirname7(join28(opts.projectRoot, wireTarget));
5989
+ const targetAbsDir = dirname7(join29(opts.projectRoot, wireTarget));
5681
5990
  const barrelImport = relativeImportPath(
5682
5991
  targetAbsDir,
5683
- join28(opts.projectRoot, ...tokensRelDir, "theme.css")
5992
+ join29(opts.projectRoot, ...tokensRelDir, "theme.css")
5684
5993
  );
5685
5994
  const snippet = `@import "${barrelImport}";`;
5686
5995
  try {
5687
5996
  if (relocateTo && currentBrand) {
5688
- const abs = join28(opts.projectRoot, wireTarget);
5997
+ const abs = join29(opts.projectRoot, wireTarget);
5689
5998
  const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
5690
- const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
5999
+ const before = existsSync17(abs) ? readFileSync25(abs, "utf8") : "";
5691
6000
  const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
5692
6001
  if (after !== before) writeFileSync4(abs, after, "utf8");
5693
6002
  }
5694
- const outcome = wireBarrel(join28(opts.projectRoot, wireTarget), barrelImport);
6003
+ const outcome = wireBarrel(join29(opts.projectRoot, wireTarget), barrelImport);
5695
6004
  wiring = { target: wireTarget, status: outcome.status, snippet };
5696
6005
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
5697
6006
  log(`
@@ -5708,7 +6017,7 @@ Could not write to ${wireTarget}: ${err.message}`);
5708
6017
  } else {
5709
6018
  const barrelImport = relativeImportPath(
5710
6019
  opts.projectRoot,
5711
- join28(opts.projectRoot, ...tokensRelDir, "theme.css")
6020
+ join29(opts.projectRoot, ...tokensRelDir, "theme.css")
5712
6021
  );
5713
6022
  const snippet = `@import "${barrelImport}";`;
5714
6023
  wiring = { target: null, status: "print-only", snippet };
@@ -5717,7 +6026,7 @@ Could not write to ${wireTarget}: ${err.message}`);
5717
6026
  if (pasteTargets.length > 0) {
5718
6027
  log("Paste the block for whichever of these is your global stylesheet:");
5719
6028
  for (const target of pasteTargets) {
5720
- const dir = dirname7(join28(opts.projectRoot, target));
6029
+ const dir = dirname7(join29(opts.projectRoot, target));
5721
6030
  const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
5722
6031
  @import "${relativeImportPath(dir, modeAbsPath)}";`;
5723
6032
  log(`
@@ -5743,6 +6052,9 @@ Could not write to ${wireTarget}: ${err.message}`);
5743
6052
  "\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."
5744
6053
  );
5745
6054
  }
6055
+ const agentFiles = ensureAgentFiles(opts.projectRoot, { claude: claudeInstalled(opts.projectRoot, opts.homeDir) });
6056
+ if (agentFiles.length) log(`
6057
+ Wrote Jig's instructions for your agent into ${agentFiles.join(" and ")}; your own text in them is untouched.`);
5746
6058
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
5747
6059
  log("\nBaseline:");
5748
6060
  log(baselineResult.report);
@@ -5765,28 +6077,28 @@ Could not write to ${wireTarget}: ${err.message}`);
5765
6077
 
5766
6078
  // src/commands/verdicts.ts
5767
6079
  import { execFileSync as execFileSync3 } from "child_process";
5768
- import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
5769
- import { join as join37, relative as relative4, resolve as resolve5 } from "path";
6080
+ import { existsSync as existsSync24, readFileSync as readFileSync32 } from "fs";
6081
+ import { join as join38, relative as relative4, resolve as resolve5, sep as sep6 } from "path";
5770
6082
 
5771
6083
  // src/rules/citations.ts
5772
- import { join as join29 } from "path";
6084
+ import { join as join30 } from "path";
5773
6085
  function citableIds(packageRoot2) {
5774
- const rulesDir = join29(packageRoot2, "rules");
6086
+ const rulesDir = join30(packageRoot2, "rules");
5775
6087
  const ids = [
5776
- ...loadRules(rulesDir, join29(packageRoot2, "rules.index.json")).map((r) => r.id),
6088
+ ...loadRules(rulesDir, join30(packageRoot2, "rules.index.json")).map((r) => r.id),
5777
6089
  ...loadSpecs(rulesDir).map((s) => s.id)
5778
6090
  ];
5779
6091
  return [...new Set(ids)].sort();
5780
6092
  }
5781
6093
 
5782
6094
  // src/probe/check.ts
5783
- import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
5784
- import { join as join34 } from "path";
6095
+ import { existsSync as existsSync21, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
6096
+ import { join as join35 } from "path";
5785
6097
 
5786
6098
  // src/probe/save.ts
5787
- import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync5 } from "fs";
6099
+ import { existsSync as existsSync20, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync28, writeFileSync as writeFileSync5 } from "fs";
5788
6100
  import { fileURLToPath as fileURLToPath2 } from "url";
5789
- import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
6101
+ import { join as join34, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
5790
6102
 
5791
6103
  // src/probe/script.ts
5792
6104
  var PROBE_VERSION = 9;
@@ -6018,24 +6330,28 @@ var PROBE_SCRIPT = `(async () => {
6018
6330
 
6019
6331
  // src/probe/browser.ts
6020
6332
  import { spawn } from "child_process";
6021
- import { existsSync as existsSync17, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
6022
- import { homedir, tmpdir } from "os";
6023
- import { join as join30 } from "path";
6333
+ import { existsSync as existsSync18, mkdtempSync, readdirSync as readdirSync11, rmSync as rmSync3 } from "fs";
6334
+ import { homedir as homedir2, tmpdir } from "os";
6335
+ import { join as join31 } from "path";
6024
6336
  var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
6025
- var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
6337
+ var CHROME_COMMANDS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser", "chrome", "msedge"];
6026
6338
  var CHROME_APPS = [
6027
6339
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
6028
6340
  "/Applications/Chromium.app/Contents/MacOS/Chromium",
6029
6341
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
6030
6342
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
6031
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
6343
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
6344
+ // Every Windows machine has Edge; Chrome is often installed per user.
6345
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
6346
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
6347
+ ...process.env.LOCALAPPDATA ? [join31(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")] : []
6032
6348
  ];
6033
6349
  function cachedBrowsers() {
6034
6350
  const roots = [
6035
- join30(homedir(), ".cache", "ms-playwright"),
6036
- join30(homedir(), "Library", "Caches", "ms-playwright"),
6037
- join30(homedir(), "AppData", "Local", "ms-playwright"),
6038
- join30(homedir(), ".cache", "puppeteer", "chrome")
6351
+ join31(homedir2(), ".cache", "ms-playwright"),
6352
+ join31(homedir2(), "Library", "Caches", "ms-playwright"),
6353
+ join31(homedir2(), "AppData", "Local", "ms-playwright"),
6354
+ join31(homedir2(), ".cache", "puppeteer", "chrome")
6039
6355
  ];
6040
6356
  const found = [];
6041
6357
  for (const root of roots) {
@@ -6055,8 +6371,8 @@ function cachedBrowsers() {
6055
6371
  ["chrome-win", "chrome.exe"],
6056
6372
  ["chrome-win64", "chrome.exe"]
6057
6373
  ]) {
6058
- const candidate = join30(root, entry, ...rel);
6059
- if (existsSync17(candidate)) found.push(candidate);
6374
+ const candidate = join31(root, entry, ...rel);
6375
+ if (existsSync18(candidate)) found.push(candidate);
6060
6376
  }
6061
6377
  }
6062
6378
  }
@@ -6065,21 +6381,25 @@ function cachedBrowsers() {
6065
6381
  function findChrome() {
6066
6382
  for (const key of CHROME_ENV) {
6067
6383
  const value = process.env[key];
6068
- if (value && existsSync17(value)) return value;
6384
+ if (value && existsSync18(value)) return value;
6069
6385
  }
6070
6386
  for (const command of CHROME_COMMANDS) {
6071
6387
  const resolved = which(command);
6072
6388
  if (resolved) return resolved;
6073
6389
  }
6074
- for (const app of CHROME_APPS) if (existsSync17(app)) return app;
6390
+ for (const app of CHROME_APPS) if (existsSync18(app)) return app;
6075
6391
  return cachedBrowsers()[0];
6076
6392
  }
6077
6393
  function which(command) {
6078
- const dirs = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
6394
+ const windows = process.platform === "win32";
6395
+ const dirs = (process.env.PATH ?? "").split(windows ? ";" : ":");
6396
+ const exts = windows ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""];
6079
6397
  for (const dir of dirs) {
6080
6398
  if (!dir) continue;
6081
- const candidate = join30(dir, command);
6082
- if (existsSync17(candidate)) return candidate;
6399
+ for (const ext of exts) {
6400
+ const candidate = join31(dir, command + ext);
6401
+ if (existsSync18(candidate)) return candidate;
6402
+ }
6083
6403
  }
6084
6404
  return void 0;
6085
6405
  }
@@ -6090,7 +6410,7 @@ async function runProbe(opts) {
6090
6410
  "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."
6091
6411
  );
6092
6412
  }
6093
- const profile = mkdtempSync(join30(tmpdir(), "jig-probe-"));
6413
+ const profile = mkdtempSync(join31(tmpdir(), "jig-probe-"));
6094
6414
  const child = spawn(chrome, [
6095
6415
  "--headless=new",
6096
6416
  "--remote-debugging-port=0",
@@ -6134,6 +6454,9 @@ async function runProbe(opts) {
6134
6454
  }
6135
6455
  }
6136
6456
  async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
6457
+ if (typeof WebSocket === "undefined") {
6458
+ throw new Error(`rendering a page needs Node 22 or later (this is Node ${process.versions.node}). Upgrade Node, then run it again.`);
6459
+ }
6137
6460
  const socket = new WebSocket(endpoint);
6138
6461
  let nextId = 1;
6139
6462
  const pending = /* @__PURE__ */ new Map();
@@ -6191,8 +6514,8 @@ async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
6191
6514
 
6192
6515
  // src/probe/serve.ts
6193
6516
  import { createServer } from "http";
6194
- import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync3 } from "fs";
6195
- import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
6517
+ import { existsSync as existsSync19, readFileSync as readFileSync26, statSync as statSync3 } from "fs";
6518
+ import { extname, join as join32, resolve as resolve3, sep as sep4 } from "path";
6196
6519
  var TYPES = {
6197
6520
  ".html": "text/html; charset=utf-8",
6198
6521
  ".htm": "text/html; charset=utf-8",
@@ -6219,19 +6542,19 @@ async function serveDirectory(dir) {
6219
6542
  const root = resolve3(dir);
6220
6543
  const server = createServer((req, res) => {
6221
6544
  let path = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
6222
- let file = resolve3(join31(root, path));
6545
+ let file = resolve3(join32(root, path));
6223
6546
  if (file !== root && !file.startsWith(root + sep4)) {
6224
6547
  res.writeHead(403).end();
6225
6548
  return;
6226
6549
  }
6227
- if (existsSync18(file) && statSync3(file).isDirectory()) file = join31(file, "index.html");
6228
- else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
6229
- if (!existsSync18(file)) {
6550
+ if (existsSync19(file) && statSync3(file).isDirectory()) file = join32(file, "index.html");
6551
+ else if (!existsSync19(file) && existsSync19(`${file}.html`)) file = `${file}.html`;
6552
+ if (!existsSync19(file)) {
6230
6553
  res.writeHead(404).end();
6231
6554
  return;
6232
6555
  }
6233
6556
  res.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream" });
6234
- res.end(readFileSync24(file));
6557
+ res.end(readFileSync26(file));
6235
6558
  });
6236
6559
  await new Promise((done) => server.listen(0, "127.0.0.1", done));
6237
6560
  const address = server.address();
@@ -6243,8 +6566,8 @@ async function serveDirectory(dir) {
6243
6566
  }
6244
6567
 
6245
6568
  // src/probe/switches.ts
6246
- import { readFileSync as readFileSync25 } from "fs";
6247
- import { join as join32 } from "path";
6569
+ import { readFileSync as readFileSync27 } from "fs";
6570
+ import { join as join33 } from "path";
6248
6571
  var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
6249
6572
  function declaredSwitches(projectRoot) {
6250
6573
  const found = /* @__PURE__ */ new Map();
@@ -6252,7 +6575,7 @@ function declaredSwitches(projectRoot) {
6252
6575
  if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
6253
6576
  let text;
6254
6577
  try {
6255
- text = readFileSync25(join32(projectRoot, file), "utf8");
6578
+ text = readFileSync27(join33(projectRoot, file), "utf8");
6256
6579
  } catch {
6257
6580
  continue;
6258
6581
  }
@@ -6287,18 +6610,18 @@ function saveProbe(opts) {
6287
6610
  }
6288
6611
  probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
6289
6612
  if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
6290
- probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
6613
+ probe.pageChecksum = checksum(readFileSync28(page, "utf8"));
6291
6614
  probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
6292
- const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6615
+ const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6293
6616
  mkdirSync7(dir, { recursive: true });
6294
- const path = join33(dir, `probe-${width}.json`);
6617
+ const path = join34(dir, `probe-${width}.json`);
6295
6618
  writeFileSync5(path, JSON.stringify(probe), "utf8");
6296
6619
  return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
6297
6620
  }
6298
6621
  function projectFile(projectRoot, path) {
6299
6622
  const abs = resolve4(projectRoot, path);
6300
6623
  if (abs !== resolve4(projectRoot) && !abs.startsWith(resolve4(projectRoot) + sep5)) return void 0;
6301
- return existsSync19(abs) ? abs : void 0;
6624
+ return existsSync20(abs) ? abs : void 0;
6302
6625
  }
6303
6626
  function pageFile(projectRoot, url) {
6304
6627
  if (!url.startsWith("file://")) return void 0;
@@ -6310,12 +6633,12 @@ function pageFile(projectRoot, url) {
6310
6633
  }
6311
6634
  const abs = resolve4(path);
6312
6635
  if (!abs.startsWith(resolve4(projectRoot))) return void 0;
6313
- return existsSync19(abs) ? abs : void 0;
6636
+ return existsSync20(abs) ? abs : void 0;
6314
6637
  }
6315
6638
  var PROBE_WIDTHS = [360, 768, 1280, 1600];
6316
6639
  async function runAndSaveProbes(opts) {
6317
6640
  const abs = resolve4(opts.projectRoot, opts.page);
6318
- if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6641
+ if (!existsSync20(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
6319
6642
  const saved = [];
6320
6643
  const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
6321
6644
  await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
@@ -6329,7 +6652,7 @@ async function runAndSaveProbes(opts) {
6329
6652
  async function withPageUrl(projectRoot, abs, serve, use) {
6330
6653
  if (!serve) return use(`file://${abs}`);
6331
6654
  const root = resolve4(projectRoot, serve);
6332
- if (!existsSync19(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
6655
+ if (!existsSync20(root)) throw new Error(`${serve} does not exist, so there is nothing to serve.`);
6333
6656
  if (!abs.startsWith(root + sep5)) throw new Error(`The page is not inside ${serve}, so serving ${serve} cannot show it.`);
6334
6657
  const server = await serveDirectory(root);
6335
6658
  try {
@@ -6340,31 +6663,31 @@ async function withPageUrl(projectRoot, abs, serve, use) {
6340
6663
  }
6341
6664
  }
6342
6665
  function recordedPage(projectRoot, surface) {
6343
- const dir = join33(projectRoot, ".jig", "critique", surface);
6344
- if (!existsSync19(dir)) return void 0;
6666
+ const dir = join34(projectRoot, ".jig", "critique", surface);
6667
+ if (!existsSync20(dir)) return void 0;
6345
6668
  for (const f of readdirSync12(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
6346
6669
  try {
6347
- const probe = JSON.parse(readFileSync26(join33(dir, f), "utf8"));
6348
- if (probe.pageFile && existsSync19(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
6670
+ const probe = JSON.parse(readFileSync28(join34(dir, f), "utf8"));
6671
+ if (probe.pageFile && existsSync20(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
6349
6672
  } catch {
6350
6673
  }
6351
6674
  }
6352
6675
  return void 0;
6353
6676
  }
6354
6677
  async function ensureProbes(opts) {
6355
- const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
6678
+ const dir = join34(opts.projectRoot, ".jig", "critique", opts.surface);
6356
6679
  const abs = resolve4(opts.projectRoot, opts.page);
6357
6680
  let current;
6358
6681
  try {
6359
- current = checksum(readFileSync26(abs, "utf8"));
6682
+ current = checksum(readFileSync28(abs, "utf8"));
6360
6683
  } catch {
6361
6684
  return { recorded: [], reason: `${opts.page} could not be read` };
6362
6685
  }
6363
- const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6686
+ const recorded = existsSync20(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
6364
6687
  const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
6365
6688
  const missing = widths.filter((width) => {
6366
6689
  try {
6367
- const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6690
+ const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
6368
6691
  return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
6369
6692
  } catch {
6370
6693
  return true;
@@ -6375,7 +6698,7 @@ async function ensureProbes(opts) {
6375
6698
  let serve;
6376
6699
  for (const width of widths) {
6377
6700
  try {
6378
- const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
6701
+ const probe = JSON.parse(readFileSync28(join34(dir, `probe-${width}.json`), "utf8"));
6379
6702
  if (probe.serveRoot) {
6380
6703
  serve = probe.serveRoot;
6381
6704
  break;
@@ -6391,9 +6714,9 @@ async function ensureProbes(opts) {
6391
6714
  return { recorded: missing };
6392
6715
  }
6393
6716
  function critiquedSurfaces(projectRoot) {
6394
- const root = join33(projectRoot, ".jig", "critique");
6395
- if (!existsSync19(root)) return [];
6396
- return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
6717
+ const root = join34(projectRoot, ".jig", "critique");
6718
+ if (!existsSync20(root)) return [];
6719
+ return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync20(join34(root, surface, "screen.json")) || existsSync20(join34(root, surface, "code.json"))));
6397
6720
  }
6398
6721
 
6399
6722
  // src/probe/check.ts
@@ -6405,7 +6728,7 @@ function stampProblem(projectRoot, file, p) {
6405
6728
  if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
6406
6729
  let current;
6407
6730
  try {
6408
- current = readFileSync27(page, "utf8");
6731
+ current = readFileSync29(page, "utf8");
6409
6732
  } catch {
6410
6733
  return `${file} was taken on ${p.pageFile}, which no longer exists.`;
6411
6734
  }
@@ -6415,11 +6738,11 @@ function stampProblem(projectRoot, file, p) {
6415
6738
  return void 0;
6416
6739
  }
6417
6740
  function readProbes(projectRoot, dir, errors) {
6418
- if (!existsSync20(dir)) return [];
6741
+ if (!existsSync21(dir)) return [];
6419
6742
  const probes = [];
6420
6743
  for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
6421
6744
  try {
6422
- const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
6745
+ const p = JSON.parse(readFileSync29(join35(dir, f), "utf8"));
6423
6746
  if (typeof p.jigProbe === "number" && p.jigProbe < PROBE_VERSION && typeof p.width === "number") {
6424
6747
  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\`.`);
6425
6748
  continue;
@@ -6523,19 +6846,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6523
6846
  }
6524
6847
 
6525
6848
  // src/check/decisions.ts
6526
- import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
6527
- import { join as join35, posix as posix3 } from "path";
6528
- var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
6849
+ import { existsSync as existsSync22, readFileSync as readFileSync30 } from "fs";
6850
+ import { join as join36, posix as posix3 } from "path";
6851
+ var HEADING4 = /^(#{2,3})\s+(.+?)\s*$/gm;
6529
6852
  var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
6530
6853
  function decisionsFile(projectRoot) {
6531
6854
  const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
6532
6855
  try {
6533
- const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
6856
+ const brand = JSON.parse(readFileSync30(join36(projectRoot, "jig.config.json"), "utf8")).brand;
6534
6857
  if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
6535
6858
  } catch {
6536
6859
  }
6537
6860
  for (const candidate of candidates) {
6538
- if (existsSync21(join35(projectRoot, candidate))) return candidate;
6861
+ if (existsSync22(join36(projectRoot, candidate))) return candidate;
6539
6862
  }
6540
6863
  return void 0;
6541
6864
  }
@@ -6548,11 +6871,11 @@ function decisionHeadings(projectRoot) {
6548
6871
  if (!path) return headings;
6549
6872
  let body;
6550
6873
  try {
6551
- body = readFileSync28(join35(projectRoot, path), "utf8");
6874
+ body = readText(join36(projectRoot, path));
6552
6875
  } catch {
6553
6876
  return headings;
6554
6877
  }
6555
- for (const match of body.matchAll(HEADING3)) {
6878
+ for (const match of body.matchAll(HEADING4)) {
6556
6879
  const name = match[2].replace(/[`*]/g, "").trim();
6557
6880
  if (!name || NOT_A_DECISION.test(name)) continue;
6558
6881
  if (!headings.has(name)) headings.set(name, match[0].trim());
@@ -6606,7 +6929,7 @@ function unsourcedReasons(current, before, opts = {}) {
6606
6929
  }
6607
6930
  return problems;
6608
6931
  }
6609
- function quotesNotFrom(current, before, words) {
6932
+ function quotesNotFrom(current, before, words, source = "the owner's words in tweak.json (`change`)") {
6610
6933
  const problems = [];
6611
6934
  for (const [name, whys] of changedWhys(current, before, true)) {
6612
6935
  for (const why of whys) {
@@ -6614,7 +6937,7 @@ function quotesNotFrom(current, before, words) {
6614
6937
  if (/inferred/i.test(label)) continue;
6615
6938
  const missing = [...why.matchAll(/["“]([^"”]{3,})["”]/g)].map((m) => m[1]).filter((q) => !quoteHeld(q, words));
6616
6939
  if (missing.length) {
6617
- problems.push(`"${name}": its \`**${label}:**\` quotes "${missing[0]}", which the owner's words in tweak.json (\`change\`) do not say. Quote the owner as tweak.json records them, or put your reading under \`**Why (inferred):**\`.`);
6940
+ 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):**\`.`);
6618
6941
  break;
6619
6942
  }
6620
6943
  }
@@ -6628,25 +6951,25 @@ function quoteHeld(quote, words) {
6628
6951
  }
6629
6952
 
6630
6953
  // src/check/spec-shape.ts
6631
- import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
6632
- import { join as join36 } from "path";
6954
+ import { existsSync as existsSync23, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
6955
+ import { join as join37 } from "path";
6633
6956
  function newestSpec(projectRoot) {
6634
- const dir = join36(projectRoot, ".jig", "specs");
6635
- if (!existsSync22(dir)) return void 0;
6957
+ const dir = join37(projectRoot, ".jig", "specs");
6958
+ if (!existsSync23(dir)) return void 0;
6636
6959
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
6637
6960
  if (files.length === 0) return void 0;
6638
- const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6639
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
6961
+ const newest = files.map((f) => ({ f, at: statSync4(join37(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
6962
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readText(join37(dir, newest)) };
6640
6963
  }
6641
6964
  function specFor(projectRoot, surface) {
6642
6965
  if (surface) {
6643
- const path = join36(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6644
- if (existsSync22(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readFileSync29(path, "utf8") };
6966
+ const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6967
+ if (existsSync23(path)) return { path: `.jig/specs/${surface}.spec.md`, slug: surface, body: readText(path) };
6645
6968
  }
6646
6969
  return newestSpec(projectRoot);
6647
6970
  }
6648
6971
  function specProblems(spec) {
6649
- const parts = spec.body.split(/^---\s*$/m);
6972
+ const parts = lf(spec.body).split(/^---\s*$/m);
6650
6973
  const front = parts.length >= 3 ? parts[1] : "";
6651
6974
  if (!front.trim()) {
6652
6975
  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.`];
@@ -6660,6 +6983,18 @@ function specProblems(spec) {
6660
6983
  if (critique !== void 0 && !/^(each|at-ship)$/i.test(critique)) {
6661
6984
  problems.push(`${spec.path}: \`critique: ${critique}\` is neither \`each\` nor \`at-ship\`. Leave it out to follow the project's default.`);
6662
6985
  }
6986
+ const motion = /^motion\s*:[ \t]*(.*)\n?((?:[ \t]+-.*\n?)*)/im.exec(front);
6987
+ if (motion) {
6988
+ const inline = motion[1].replace(/\s+#.*$/, "").trim();
6989
+ const items = motion[2].split("\n").map((l) => l.replace(/^[ \t]+-\s*/, "").trim()).filter(Boolean);
6990
+ if (!items.length && !/^none\b(\s*$|\s*[—–:,;(-])/i.test(inline)) {
6991
+ 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\`.`);
6992
+ }
6993
+ const unsaid = items.filter((i) => !/\bon\b/i.test(i) || !/\btells?\b/i.test(i));
6994
+ if (unsaid.length) {
6995
+ problems.push(`${spec.path}: ${unsaid.length === 1 ? "a movement in `motion:` does" : `${unsaid.length} movements in \`motion:\` do`} not say what triggers it and what it tells the reader ("${unsaid[0]}"). Write \`on <trigger>; tells <what>\`, or cut the movement.`);
6996
+ }
6997
+ }
6663
6998
  for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
6664
6999
  if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
6665
7000
  }
@@ -6723,6 +7058,41 @@ function specIndexableField(front) {
6723
7058
  if (value === "false" || value === "no") return false;
6724
7059
  return "unreadable";
6725
7060
  }
7061
+ var SPEC_FIELDS = [
7062
+ "feature",
7063
+ "surface",
7064
+ "route",
7065
+ "mode",
7066
+ "indexable",
7067
+ "title",
7068
+ "description",
7069
+ "sizes",
7070
+ "switches",
7071
+ "states",
7072
+ "motion",
7073
+ "decisions",
7074
+ "later",
7075
+ "mockup",
7076
+ "mockup_at",
7077
+ "deviations",
7078
+ "critique",
7079
+ "superseded_by",
7080
+ "confirmed"
7081
+ ];
7082
+ var topLevelKeys = (body) => [...(body.split(/^---\s*$/m)[1] ?? "").matchAll(/^([A-Za-z_][\w-]*)\s*:/gm)].map((m) => m[1]);
7083
+ function newFieldProblems(spec, then) {
7084
+ spec = { ...spec, body: lf(spec.body) };
7085
+ then = lf(then);
7086
+ const had = new Set(topLevelKeys(then));
7087
+ const known = new Set(SPEC_FIELDS);
7088
+ const added = [...new Set(topLevelKeys(spec.body))].filter((k) => !known.has(k) && !had.has(k));
7089
+ if (!added.length) return [];
7090
+ const near = (k) => SPEC_FIELDS.find((f) => k.includes(f) || f.startsWith(k.replace(/[-_].*$/, "")));
7091
+ return added.map((k) => {
7092
+ const hint = near(k);
7093
+ 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(", ")}.`;
7094
+ });
7095
+ }
6726
7096
 
6727
7097
  // src/commands/verdicts.ts
6728
7098
  var VERDICTS = ["ok", "finding", "n/a"];
@@ -6749,19 +7119,19 @@ function reasonProblems(file, judged, errors) {
6749
7119
  }
6750
7120
  }
6751
7121
  function readJson(path, errors) {
6752
- if (!existsSync23(path)) return null;
7122
+ if (!existsSync24(path)) return null;
6753
7123
  try {
6754
- return JSON.parse(readFileSync30(path, "utf8"));
7124
+ return JSON.parse(readFileSync32(path, "utf8"));
6755
7125
  } catch (e) {
6756
7126
  errors.push(`${path}: not valid JSON (${e.message})`);
6757
7127
  return {};
6758
7128
  }
6759
7129
  }
6760
7130
  function specIndexable(projectRoot, surface) {
6761
- const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
7131
+ const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6762
7132
  let front = "";
6763
7133
  try {
6764
- front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
7134
+ front = readText(path).split(/^---\s*$/m)[1] ?? "";
6765
7135
  } catch {
6766
7136
  }
6767
7137
  const declared = specIndexableField(front);
@@ -6770,13 +7140,17 @@ function specIndexable(projectRoot, surface) {
6770
7140
  return mode !== "product" && mode !== "operator";
6771
7141
  }
6772
7142
  function specNeedsNav(projectRoot, surface) {
6773
- const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
6774
- if (!existsSync23(path)) return false;
6775
- const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
7143
+ const path = join38(projectRoot, ".jig", "specs", `${surface}.spec.md`);
7144
+ if (!existsSync24(path)) return false;
7145
+ const front = readText(path).split(/^---\s*$/m)[1] ?? "";
6776
7146
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
6777
7147
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
6778
7148
  return navField || navRegion;
6779
7149
  }
7150
+ function unreadFields(label, file, known) {
7151
+ if (!file || typeof file !== "object") return [];
7152
+ 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.`);
7153
+ }
6780
7154
  function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors, decisionList = []) {
6781
7155
  const total = required.length + extraRequired.length;
6782
7156
  if (file === null) {
@@ -6803,7 +7177,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6803
7177
  seen.add(id);
6804
7178
  const pass = otherPass.get(id);
6805
7179
  if (!required.includes(id) && !extraAllowed.has(id)) {
6806
- errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
7180
+ errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict. A difference from the spec, or from a source the page quotes, is not a rule: list it under \`differences\` in the same file.`);
6807
7181
  continue;
6808
7182
  }
6809
7183
  if (typeof v.verdict !== "string" || !RULE_VERDICTS.includes(v.verdict)) {
@@ -6824,6 +7198,17 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6824
7198
  if (v.verdict === "finding") findings++;
6825
7199
  }
6826
7200
  reasonProblems(`${name}.json`, reasons, errors);
7201
+ const differences = file.differences;
7202
+ if (differences !== void 0) {
7203
+ if (!Array.isArray(differences)) errors.push(`${name}.json: \`differences\` is not a list.`);
7204
+ else for (const d of differences) {
7205
+ const said = (x) => typeof x === "string" && x.trim() !== "";
7206
+ if (!said(d?.what) || !said(d?.where) || !said(d?.against)) {
7207
+ errors.push(`${name}.json: a difference needs \`what\` (what differs), \`where\` (the page's file and line) and \`against\` (the spec line or source it differs from).`);
7208
+ } else findings++;
7209
+ }
7210
+ }
7211
+ errors.push(...unreadFields(`${name}.json`, file, ["verdicts", "differences", "rendered", "artefacts"]));
6827
7212
  const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
6828
7213
  if (missing.length) {
6829
7214
  errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
@@ -6834,7 +7219,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
6834
7219
  function checkDecisions(projectRoot, dir, errors) {
6835
7220
  const required = decisionNames(projectRoot);
6836
7221
  if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
6837
- const file = readJson(join37(dir, "decisions.json"), errors);
7222
+ const file = readJson(join38(dir, "decisions.json"), errors);
6838
7223
  if (!file) {
6839
7224
  errors.push(
6840
7225
  `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.`
@@ -6843,6 +7228,7 @@ function checkDecisions(projectRoot, dir, errors) {
6843
7228
  }
6844
7229
  const list = Array.isArray(file.verdicts) ? file.verdicts : [];
6845
7230
  if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
7231
+ errors.push(...unreadFields("decisions.json", file, ["verdicts"]));
6846
7232
  const seen = /* @__PURE__ */ new Set();
6847
7233
  const reasons = [];
6848
7234
  let findings = 0;
@@ -6863,7 +7249,7 @@ function checkDecisions(projectRoot, dir, errors) {
6863
7249
  }
6864
7250
  seen.add(match);
6865
7251
  if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
6866
- errors.push(`decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
7252
+ errors.push(v.verdict === "ruled" ? `decisions.json: "${match}" is marked ruled. A decision is not excused by a decision: judge whether the page follows it, ok, finding or n/a. \`ruled\` is for a rule in screen.json or code.json that a decision overrides.` : `decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
6867
7253
  continue;
6868
7254
  }
6869
7255
  const reason = typeof v.reason === "string" ? v.reason.trim() : "";
@@ -6888,7 +7274,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
6888
7274
  if (!path) return [];
6889
7275
  const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
6890
7276
  try {
6891
- const verdictFile = relative4(projectRoot, join37(dir, "decisions.json"));
7277
+ const verdictFile = relative4(projectRoot, join38(dir, "decisions.json")).split(sep6).join("/");
6892
7278
  if (git(["status", "--porcelain", "--", verdictFile])) return [];
6893
7279
  const judgedIn = git(["log", "-1", "--format=%H", "--", verdictFile]);
6894
7280
  if (!judgedIn) return [];
@@ -6912,7 +7298,7 @@ function decisionsSince(projectRoot, dir, unjudged) {
6912
7298
  function verifyVerdicts(opts) {
6913
7299
  const root = opts.packageRoot ?? assetRoot();
6914
7300
  const errors = [];
6915
- const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
7301
+ const index = JSON.parse(readFileSync32(join38(root, "rules.index.json"), "utf8"));
6916
7302
  const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
6917
7303
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
6918
7304
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -6921,9 +7307,9 @@ function verifyVerdicts(opts) {
6921
7307
  ...judgment.map((r) => [r.id, r.pass ?? ""])
6922
7308
  ]);
6923
7309
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
6924
- const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
6925
- const screenFile = readJson(join37(dir, "screen.json"), errors);
6926
- const codeFile = readJson(join37(dir, "code.json"), errors);
7310
+ const dir = join38(opts.projectRoot, ".jig", "critique", opts.surface);
7311
+ const screenFile = readJson(join38(dir, "screen.json"), errors);
7312
+ const codeFile = readJson(join38(dir, "code.json"), errors);
6927
7313
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
6928
7314
  const decisionList = decisionNames(opts.projectRoot);
6929
7315
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors, decisionList);
@@ -6931,7 +7317,7 @@ function verifyVerdicts(opts) {
6931
7317
  let rendered = false;
6932
7318
  if (screenFile && screenFile.rendered === true) {
6933
7319
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
6934
- const absent = artefacts.filter((a) => !existsSync23(resolve5(opts.projectRoot, a)));
7320
+ const absent = artefacts.filter((a) => !existsSync24(resolve5(opts.projectRoot, a)));
6935
7321
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
6936
7322
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
6937
7323
  else rendered = true;
@@ -6952,7 +7338,7 @@ function verifyVerdicts(opts) {
6952
7338
  };
6953
7339
  let specFront = "";
6954
7340
  try {
6955
- specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
7341
+ specFront = readText(join38(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`)).split(/^---\s*$/m)[1] ?? "";
6956
7342
  } catch {
6957
7343
  }
6958
7344
  if (specIndexableField(specFront) === "unreadable") {
@@ -6987,7 +7373,7 @@ function previousFindings(projectRoot, dir) {
6987
7373
  const out = /* @__PURE__ */ new Map();
6988
7374
  let text;
6989
7375
  try {
6990
- text = at ? git(["show", `${at}:./${rel}/${f}`]) : readFileSync30(join37(dir, f), "utf8");
7376
+ text = at ? lf(git(["show", `${at}:./${rel}/${f}`])) : readText(join38(dir, f));
6991
7377
  } catch {
6992
7378
  return out;
6993
7379
  }
@@ -7069,18 +7455,17 @@ function sourceChanges(git, from, to) {
7069
7455
  }
7070
7456
 
7071
7457
  // src/commands/gate.ts
7072
- import { existsSync as existsSync26, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync35, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7458
+ import { existsSync as existsSync27, mkdirSync as mkdirSync8, readdirSync as readdirSync16, readFileSync as readFileSync36, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
7073
7459
  import { createHash as createHash3 } from "crypto";
7074
- import { join as join42 } from "path";
7460
+ import { join as join43 } from "path";
7075
7461
 
7076
7462
  // src/check/mockup-drawing.ts
7077
- import { readFileSync as readFileSync31 } from "fs";
7078
- import { join as join38 } from "path";
7463
+ import { join as join39 } from "path";
7079
7464
  var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
7080
7465
  function mockupDrawingProblems(root, specBody, at) {
7081
7466
  let html;
7082
7467
  try {
7083
- html = readFileSync31(join38(root, at), "utf8");
7468
+ html = readText(join39(root, at));
7084
7469
  } catch {
7085
7470
  return [];
7086
7471
  }
@@ -7141,7 +7526,7 @@ function frameRegionProblems(frames, specBody, at) {
7141
7526
  function approvedDrawingProblems(root, specBody, at) {
7142
7527
  let html;
7143
7528
  try {
7144
- html = readFileSync31(join38(root, at), "utf8");
7529
+ html = readText(join39(root, at));
7145
7530
  } catch {
7146
7531
  return [];
7147
7532
  }
@@ -7247,12 +7632,12 @@ function sizeBlock2(sizes, size) {
7247
7632
  import { execFileSync as execFileSync4 } from "child_process";
7248
7633
 
7249
7634
  // src/commands/ship.ts
7250
- import { existsSync as existsSync24, readdirSync as readdirSync15, readFileSync as readFileSync33 } from "fs";
7251
- import { join as join40 } from "path";
7635
+ import { existsSync as existsSync25, readdirSync as readdirSync15, readFileSync as readFileSync34 } from "fs";
7636
+ import { join as join41 } from "path";
7252
7637
 
7253
7638
  // src/commands/seo.ts
7254
- import { readFileSync as readFileSync32 } from "fs";
7255
- import { join as join39 } from "path";
7639
+ import { readFileSync as readFileSync33 } from "fs";
7640
+ import { join as join40 } from "path";
7256
7641
  var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
7257
7642
  var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
7258
7643
  var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
@@ -7293,7 +7678,7 @@ function seo(opts) {
7293
7678
  for (const file of files) {
7294
7679
  let raw;
7295
7680
  try {
7296
- raw = readFileSync32(join39(root, file), "utf8");
7681
+ raw = readFileSync33(join40(root, file), "utf8");
7297
7682
  } catch {
7298
7683
  continue;
7299
7684
  }
@@ -7358,7 +7743,7 @@ function critiqueAtShip(projectRoot, specBody) {
7358
7743
  const own = specCritique(specBody);
7359
7744
  if (own) return own === "at-ship";
7360
7745
  try {
7361
- return JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7746
+ return JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8")).critique === "at-ship";
7362
7747
  } catch {
7363
7748
  return false;
7364
7749
  }
@@ -7366,14 +7751,14 @@ function critiqueAtShip(projectRoot, specBody) {
7366
7751
  function tweakDeferred(dir) {
7367
7752
  let t;
7368
7753
  try {
7369
- t = JSON.parse(readFileSync33(join40(dir, "tweak.json"), "utf8"));
7754
+ t = JSON.parse(readFileSync34(join41(dir, "tweak.json"), "utf8"));
7370
7755
  } catch {
7371
7756
  return false;
7372
7757
  }
7373
7758
  if (t.deferred === void 0 || t.deferred === false) return false;
7374
7759
  let judged;
7375
7760
  try {
7376
- judged = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).tweak;
7761
+ judged = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).tweak;
7377
7762
  } catch {
7378
7763
  }
7379
7764
  return judged !== t.at;
@@ -7384,27 +7769,37 @@ function pageChecksum(html) {
7384
7769
  function changedSinceLock(projectRoot, dir) {
7385
7770
  let lock;
7386
7771
  try {
7387
- lock = JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8"));
7772
+ lock = JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8"));
7388
7773
  } catch {
7389
7774
  return void 0;
7390
7775
  }
7391
7776
  const file = lock.page?.file;
7392
7777
  if (!file || !lock.page?.checksum) return void 0;
7393
7778
  try {
7394
- const html = readFileSync33(join40(projectRoot, file), "utf8");
7395
- return pageChecksum(html) !== lock.page.checksum && checksum(html) !== lock.page.checksum ? file : void 0;
7779
+ const html = readFileSync34(join41(projectRoot, file), "utf8");
7780
+ const seen = [pageChecksum(html), checksum(html)];
7781
+ return seen.includes(lock.page.checksum) ? void 0 : file;
7396
7782
  } catch {
7397
7783
  return void 0;
7398
7784
  }
7399
7785
  }
7786
+ function supersededBy(front) {
7787
+ const named = /^\s*superseded_by\s*:\s*(.*)$/im.exec(front)?.[1]?.replace(/\s+#.*$/, "").trim();
7788
+ return named ? named : void 0;
7789
+ }
7400
7790
  function pageStatus(projectRoot, surface) {
7401
- const spec = readFileSync33(join40(projectRoot, ".jig", "specs", `${surface}.spec.md`), "utf8");
7791
+ const spec = readText(join41(projectRoot, ".jig", "specs", `${surface}.spec.md`));
7402
7792
  const front = spec.split(/^---\s*$/m)[1] ?? "";
7403
7793
  if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) {
7404
7794
  return { surface, state: "in-progress", detail: "its spec is not confirmed, so nothing of it is built to ship" };
7405
7795
  }
7406
- const dir = join40(projectRoot, ".jig", "critique", surface);
7407
- if (!existsSync24(join40(dir, "screen.json")) && !existsSync24(join40(dir, "code.json"))) {
7796
+ const successor = supersededBy(front);
7797
+ if (successor !== void 0) {
7798
+ const file = successor.replace(/(\.spec\.md)?$/, ".spec.md");
7799
+ 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` };
7800
+ }
7801
+ const dir = join41(projectRoot, ".jig", "critique", surface);
7802
+ if (!existsSync25(join41(dir, "screen.json")) && !existsSync25(join41(dir, "code.json"))) {
7408
7803
  return { surface, state: "never", detail: "never critiqued" };
7409
7804
  }
7410
7805
  if (tweakDeferred(dir)) return { surface, state: "deferred", detail: "a tweak deferred its re-judge" };
@@ -7414,7 +7809,7 @@ function pageStatus(projectRoot, surface) {
7414
7809
  const stale = v.errors.find((e) => /was taken (on|by) an older/.test(e));
7415
7810
  let judgedPage = false;
7416
7811
  try {
7417
- judgedPage = !!JSON.parse(readFileSync33(join40(dir, "verdicts.lock"), "utf8")).page?.checksum;
7812
+ judgedPage = !!JSON.parse(readFileSync34(join41(dir, "verdicts.lock"), "utf8")).page?.checksum;
7418
7813
  } catch {
7419
7814
  }
7420
7815
  if (stale && !judgedPage) return { surface, state: "changed", detail: "the page changed after its probes were taken" };
@@ -7430,17 +7825,19 @@ function ship(opts) {
7430
7825
  const mechanicalErrors = mechanical.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
7431
7826
  const seoResult = seo({ projectRoot: root });
7432
7827
  const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
7433
- const specsDir = join40(root, ".jig", "specs");
7434
- const surfaces = existsSync24(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
7828
+ const specsDir = join41(root, ".jig", "specs");
7829
+ const surfaces = existsSync25(specsDir) ? readdirSync15(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
7435
7830
  const pages = surfaces.map((s) => pageStatus(root, s));
7436
- const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress");
7437
- const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0;
7831
+ const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
7832
+ const agentFiles = agentFileProblems(root);
7833
+ const ready = mechanicalErrors === 0 && seoErrors === 0 && owed.length === 0 && agentFiles.length === 0;
7438
7834
  const count = (state) => pages.filter((p) => p.state === state).length;
7439
- const line = `JIG_SHIP: ready=${ready ? "yes" : "no"} mechanical=${mechanicalErrors} seo=${seoErrors} pages=${pages.length} judged=${count("judged")} owed=${owed.length} in-progress=${count("in-progress")}`;
7440
- const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7" };
7835
+ 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")}`;
7836
+ const mark = { judged: "\u2713", never: "\u2717", changed: "\u2717", reprobe: "\u2717", deferred: "\u2717", incomplete: "\u2717", findings: "\u2717", "in-progress": "\xB7", superseded: "\xB7" };
7441
7837
  const report2 = [
7442
7838
  ` ${mechanicalErrors === 0 ? "\u2713" : "\u2717"} check --all --ci: ${mechanicalErrors} mechanical error${mechanicalErrors === 1 ? "" : "s"}`,
7443
7839
  ` ${seoErrors === 0 ? "\u2713" : "\u2717"} seo: ${seoErrors} error${seoErrors === 1 ? "" : "s"}`,
7840
+ ...agentFiles.map((p) => ` \u2717 ${p}`),
7444
7841
  ...pages.map((p) => ` ${mark[p.state]} ${p.surface}: ${p.detail}`),
7445
7842
  owed.length ? ` Critique each page marked \u2717 (\`/jig critique <page>\`); fix what it finds, or record the owner's ruling, and run \`jig ship\` again.` : "",
7446
7843
  ` Not checked by Jig, here or anywhere: ${NOT_COVERED}.`,
@@ -7450,11 +7847,37 @@ function ship(opts) {
7450
7847
  }
7451
7848
 
7452
7849
  // src/check/spec-checked.ts
7453
- import { existsSync as existsSync25, readFileSync as readFileSync34 } from "fs";
7850
+ import { existsSync as existsSync26, readFileSync as readFileSync35 } from "fs";
7454
7851
  import { createHash as createHash2 } from "crypto";
7455
- import { join as join41 } from "path";
7852
+ import { join as join42 } from "path";
7853
+
7854
+ // src/check/owner-word.ts
7855
+ var SAYS = {
7856
+ 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,
7857
+ 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,
7858
+ 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,
7859
+ 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
7860
+ };
7861
+ function saysSo(word, text) {
7862
+ return SAYS[word].test(text);
7863
+ }
7864
+ var WHAT = {
7865
+ confirm: "confirm the spec",
7866
+ approve: "approve the drawing",
7867
+ skip: "skip the mockup",
7868
+ defer: "leave the re-judge for later"
7869
+ };
7870
+ function ownerWordProblem(word, quoted, owner, where) {
7871
+ const q = quoted.replace(/^["“]|["”]$/g, "").trim();
7872
+ if (!q) return `${where} records the owner's word to ${WHAT[word]} without their words. Quote what they said.`;
7873
+ 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.`;
7874
+ 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.`;
7875
+ return void 0;
7876
+ }
7877
+
7878
+ // src/check/spec-checked.ts
7456
7879
  function specChecksum(body) {
7457
- return createHash2("sha256").update(body.replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7880
+ return createHash2("sha256").update(body.replace(/\r\n/g, "\n").replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7458
7881
  }
7459
7882
  var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7460
7883
  function mockupWordProblems(spec, then, owner) {
@@ -7467,10 +7890,8 @@ function mockupWordProblems(spec, then, owner) {
7467
7890
  if (!quoted) {
7468
7891
  return [`${spec.path}: \`mockup: ${word}\` is recorded without the owner's words. Write \`mockup: ${word} \u2014 "<what they said>"\`, quoting the reply that ${word === "approved" ? "approved it" : "said to skip it"}.`];
7469
7892
  }
7470
- if (!quoteHeld(quoted, owner)) {
7471
- return [`${spec.path}: \`mockup: ${word}\` quotes "${quoted}", which the owner did not say in this session. Quote their reply as they wrote it; ${word === "approved" ? "an approval" : "a skip"} nobody gave is not one.`];
7472
- }
7473
- return [];
7893
+ const problem = ownerWordProblem(word === "approved" ? "approve" : "skip", quoted, owner, `${spec.path}: \`mockup: ${word}\``);
7894
+ return problem ? [problem] : [];
7474
7895
  }
7475
7896
  function mockupPending(body) {
7476
7897
  const now = mockupLine(body);
@@ -7505,7 +7926,7 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7505
7926
  const recordPath = `.jig/specs/${spec.slug}.checked.json`;
7506
7927
  let record;
7507
7928
  try {
7508
- record = JSON.parse(readFileSync34(join41(root, recordPath), "utf8"));
7929
+ record = JSON.parse(readFileSync35(join42(root, recordPath), "utf8"));
7509
7930
  } catch {
7510
7931
  problems.push(
7511
7932
  `${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.`
@@ -7534,16 +7955,16 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7534
7955
  if (unsaid.length) problems.push(`${recordPath}: ${unsaid.length} unchecked ${unsaid.length === 1 ? "fact says" : "facts say"} nothing of why. Say in \`note\` why the source could not be opened; the owner opens it instead.`);
7535
7956
  const missing = facts.map((f) => typeof f?.source === "string" ? f.source.trim() : "").filter((s) => s && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)).filter((s) => {
7536
7957
  const [, file, line] = /^(.+?)(?::(\d+)(?:-\d+)?)?$/.exec(s) ?? [];
7537
- if (!file || !existsSync25(join41(root, file))) return true;
7958
+ if (!file || !existsSync26(join42(root, file))) return true;
7538
7959
  if (!line) return false;
7539
7960
  try {
7540
- return Number(line) > readFileSync34(join41(root, file), "utf8").split("\n").length;
7961
+ return Number(line) > readFileSync35(join42(root, file), "utf8").split("\n").length;
7541
7962
  } catch {
7542
7963
  return true;
7543
7964
  }
7544
7965
  });
7545
7966
  if (missing.length) {
7546
- problems.push(`${recordPath} cites ${missing.length === 1 ? "a source" : "sources"} the project does not have: ${missing.slice(0, 4).join(", ")}. A fact is checked at a source that exists; name it as a path from the project root, or a URL.`);
7967
+ 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\`.`);
7547
7968
  }
7548
7969
  return problems;
7549
7970
  }
@@ -7551,10 +7972,10 @@ function specCheckProblems(root, spec, then, owner, decisions) {
7551
7972
  // src/commands/gate.ts
7552
7973
  var MAX_BLOCKS = 3;
7553
7974
  function lastJigInvocation(transcriptPath) {
7554
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
7975
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7555
7976
  let text;
7556
7977
  try {
7557
- text = readFileSync35(transcriptPath, "utf8");
7978
+ text = readFileSync36(transcriptPath, "utf8");
7558
7979
  } catch {
7559
7980
  return void 0;
7560
7981
  }
@@ -7579,10 +8000,10 @@ function lastJigInvocation(transcriptPath) {
7579
8000
  return found;
7580
8001
  }
7581
8002
  function ownerWords(transcriptPath) {
7582
- if (!transcriptPath || !existsSync26(transcriptPath)) return "";
8003
+ if (!transcriptPath || !existsSync27(transcriptPath)) return "";
7583
8004
  const said = [];
7584
8005
  try {
7585
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8006
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7586
8007
  if (!line.includes('"user"')) continue;
7587
8008
  let entry;
7588
8009
  try {
@@ -7602,11 +8023,46 @@ function ownerWords(transcriptPath) {
7602
8023
  }
7603
8024
  return said.join("\n");
7604
8025
  }
8026
+ function namedSpec(root, surface) {
8027
+ if (!surface) return specFor(root, void 0);
8028
+ return existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`)) ? specFor(root, surface) : void 0;
8029
+ }
8030
+ function isConfirmed(body) {
8031
+ return /^\s*confirmed\s*:\s*true\b/im.test(body.split(/^---\s*$/m)[1] ?? "");
8032
+ }
8033
+ function ownerReplyAfter(transcriptPath, asked) {
8034
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
8035
+ const turns = [];
8036
+ try {
8037
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
8038
+ let entry;
8039
+ try {
8040
+ entry = JSON.parse(line);
8041
+ } catch {
8042
+ continue;
8043
+ }
8044
+ if (entry.type !== "user" && entry.type !== "assistant" || entry.isMeta) continue;
8045
+ const content = entry.message?.content;
8046
+ const texts = typeof content === "string" ? [content] : Array.isArray(content) ? content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text) : [];
8047
+ for (const text of texts) turns.push({ who: entry.type === "user" ? "owner" : "agent", text });
8048
+ }
8049
+ } catch {
8050
+ return void 0;
8051
+ }
8052
+ let answer;
8053
+ turns.forEach((t, i) => {
8054
+ if (t.who !== "agent" || !asked.test(t.text)) return;
8055
+ const reply = [];
8056
+ for (let j = i + 1; j < turns.length && turns[j].who === "owner"; j++) reply.push(turns[j].text);
8057
+ if (reply.length) answer = reply.join("\n");
8058
+ });
8059
+ return answer;
8060
+ }
7605
8061
  function lastAssistantText(transcriptPath) {
7606
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
8062
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7607
8063
  let found;
7608
8064
  try {
7609
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8065
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7610
8066
  if (!line.includes('"assistant"')) continue;
7611
8067
  let entry;
7612
8068
  try {
@@ -7630,9 +8086,9 @@ function asksOwner(text) {
7630
8086
  }
7631
8087
  var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup", "tweak"]);
7632
8088
  function sessionStart(transcriptPath) {
7633
- if (!transcriptPath || !existsSync26(transcriptPath)) return void 0;
8089
+ if (!transcriptPath || !existsSync27(transcriptPath)) return void 0;
7634
8090
  try {
7635
- for (const line of readFileSync35(transcriptPath, "utf8").split("\n")) {
8091
+ for (const line of readFileSync36(transcriptPath, "utf8").split("\n")) {
7636
8092
  const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
7637
8093
  if (m) {
7638
8094
  const t = Date.parse(m[1]);
@@ -7647,20 +8103,20 @@ function verdictsMtime(dir) {
7647
8103
  let newest = 0;
7648
8104
  for (const name of VERDICT_FILES) {
7649
8105
  try {
7650
- newest = Math.max(newest, statSync5(join42(dir, name)).mtimeMs);
8106
+ newest = Math.max(newest, statSync5(join43(dir, name)).mtimeMs);
7651
8107
  } catch {
7652
8108
  }
7653
8109
  }
7654
8110
  return newest;
7655
8111
  }
7656
8112
  function surfacesInPlay(root, command, transcriptPath, surface) {
7657
- const critiqueDir = join42(root, ".jig", "critique");
7658
- if (!existsSync26(critiqueDir)) return [];
8113
+ const critiqueDir = join43(root, ".jig", "critique");
8114
+ if (!existsSync27(critiqueDir)) return [];
7659
8115
  const all = readdirSync16(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
7660
8116
  const start = sessionStart(transcriptPath);
7661
8117
  if (start === void 0) return all;
7662
8118
  const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
7663
- return all.filter((s) => s === current || verdictsMtime(join42(critiqueDir, s)) >= start - 1e3);
8119
+ return all.filter((s) => s === current || verdictsMtime(join43(critiqueDir, s)) >= start - 1e3);
7664
8120
  }
7665
8121
  function surfacesToProbe(root, input) {
7666
8122
  const invocation = lastJigInvocation(input.transcript_path);
@@ -7668,9 +8124,13 @@ function surfacesToProbe(root, input) {
7668
8124
  }
7669
8125
  function fileAtSessionStart(root, path, start) {
7670
8126
  const git = (args) => execFileSync4("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
8127
+ try {
8128
+ if (start !== void 0 && statSync5(join43(root, path)).mtimeMs < start - 1e3) return readText(join43(root, path));
8129
+ } catch {
8130
+ }
7671
8131
  try {
7672
8132
  const base = start === void 0 ? "HEAD" : git(["rev-list", "-1", `--before=@${Math.floor(start / 1e3)}`, "HEAD"]).trim();
7673
- return base ? git(["show", `${base}:./${path}`]) : "";
8133
+ return base ? lf(git(["show", `${base}:./${path}`])) : "";
7674
8134
  } catch {
7675
8135
  return "";
7676
8136
  }
@@ -7683,7 +8143,7 @@ function changedOutsideRecords(root, start) {
7683
8143
  const changed = git(["diff", "--name-only", base, "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean);
7684
8144
  const created = git(["ls-files", "--others", "--exclude-standard", "--", ".", ":(exclude).jig"]).split("\n").filter(Boolean).filter((f) => {
7685
8145
  try {
7686
- return start === void 0 || statSync5(join42(root, f)).mtimeMs >= start - 1e3;
8146
+ return start === void 0 || statSync5(join43(root, f)).mtimeMs >= start - 1e3;
7687
8147
  } catch {
7688
8148
  return false;
7689
8149
  }
@@ -7698,8 +8158,8 @@ function drawingChangedAfterApproval(root, specPath, at) {
7698
8158
  try {
7699
8159
  const approvedIn = git(["log", "-1", "--format=%H", "-G", "^mockup[[:space:]]*:[[:space:]]*approved", "--", specPath]).trim();
7700
8160
  if (!approvedIn) return void 0;
7701
- const then = git(["show", `${approvedIn}:./${at}`]);
7702
- return then !== readFileSync35(join42(root, at), "utf8") ? approvedIn.slice(0, 7) : void 0;
8161
+ const then = lf(git(["show", `${approvedIn}:./${at}`]));
8162
+ return then !== readText(join43(root, at)) ? approvedIn.slice(0, 7) : void 0;
7703
8163
  } catch {
7704
8164
  return void 0;
7705
8165
  }
@@ -7711,18 +8171,25 @@ function commandProblems(root, command, surface, start, owner = "") {
7711
8171
  const found = decisionsFile(root);
7712
8172
  if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
7713
8173
  else {
7714
- const body = readFileSync35(join42(root, found), "utf8");
8174
+ const body = readText(join43(root, found));
7715
8175
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
7716
8176
  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.`");
7717
8177
  }
7718
8178
  if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
7719
- problems.push(...unsourcedReasons(body, fileAtSessionStart(root, found, start), { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
8179
+ const then = fileAtSessionStart(root, found, start);
8180
+ problems.push(...unsourcedReasons(body, then, { newWhysOnly: true }).map((p) => `DECISIONS.md: ${p}`));
8181
+ if (owner) problems.push(...quotesNotFrom(body, then, owner, "the owner's words in this session").map((p) => `DECISIONS.md: ${p}`));
7720
8182
  }
7721
8183
  }
8184
+ if (command === "spec" && surface && !existsSync27(join43(root, ".jig", "specs", `${surface}.spec.md`))) {
8185
+ 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.`);
8186
+ return problems;
8187
+ }
7722
8188
  if (command === "spec" || command === "mockup" || command === "make" || command === "critique" || command === "tweak") {
7723
8189
  if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
7724
8190
  else {
7725
8191
  problems.push(...specProblems(spec));
8192
+ problems.push(...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start)));
7726
8193
  if (problems.length === 0) problems.push(...navProblems(spec));
7727
8194
  }
7728
8195
  }
@@ -7737,12 +8204,12 @@ function commandProblems(root, command, surface, start, owner = "") {
7737
8204
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7738
8205
  if (/^approved/i.test(mockup)) {
7739
8206
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
7740
- else if (!/^https?:/i.test(at) && !existsSync26(join42(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
8207
+ else if (!/^https?:/i.test(at) && !existsSync27(join43(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
7741
8208
  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.`);
7742
8209
  }
7743
- if (at && /\.html?$/i.test(at) && existsSync26(join42(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
8210
+ if (at && /\.html?$/i.test(at) && existsSync27(join43(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
7744
8211
  }
7745
- if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner));
8212
+ if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner, start));
7746
8213
  if ((command === "spec" || command === "make") && spec) {
7747
8214
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7748
8215
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
@@ -7768,7 +8235,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7768
8235
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7769
8236
  const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
7770
8237
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7771
- if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
8238
+ if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
7772
8239
  const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
7773
8240
  if (changedAfter) {
7774
8241
  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.`);
@@ -7780,7 +8247,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7780
8247
  if (found) {
7781
8248
  let now = "";
7782
8249
  try {
7783
- now = readFileSync35(join42(root, found), "utf8");
8250
+ now = readText(join43(root, found));
7784
8251
  } catch {
7785
8252
  }
7786
8253
  const then = fileAtSessionStart(root, found, start);
@@ -7794,13 +8261,13 @@ function commandProblems(root, command, surface, start, owner = "") {
7794
8261
  if (found) {
7795
8262
  let now = "";
7796
8263
  try {
7797
- now = readFileSync35(join42(root, found), "utf8");
8264
+ now = readText(join43(root, found));
7798
8265
  } catch {
7799
8266
  }
7800
8267
  const then = fileAtSessionStart(root, found, start);
7801
8268
  if (now && now !== then) {
7802
8269
  problems.push(...unsourcedReasons(now, then, { newWhysOnly: true }).map((p) => `${found}: ${p}`));
7803
- const change = readTweak(join42(root, ".jig", "critique", spec.slug))?.change;
8270
+ const change = readTweak(join43(root, ".jig", "critique", spec.slug))?.change;
7804
8271
  if (change) problems.push(...quotesNotFrom(now, then, change).map((p) => `${found}: ${p}`));
7805
8272
  }
7806
8273
  }
@@ -7809,7 +8276,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7809
8276
  for (const file of modeFiles(root)) {
7810
8277
  let now = "";
7811
8278
  try {
7812
- now = readFileSync35(join42(root, file), "utf8");
8279
+ now = readText(join43(root, file));
7813
8280
  } catch {
7814
8281
  continue;
7815
8282
  }
@@ -7820,8 +8287,8 @@ function commandProblems(root, command, surface, start, owner = "") {
7820
8287
  }
7821
8288
  }
7822
8289
  if (command === "critique") {
7823
- const dir = join42(root, ".jig", "critique");
7824
- const surfaces = existsSync26(dir) ? readdirSync16(dir).filter((s) => existsSync26(join42(dir, s, "screen.json")) || existsSync26(join42(dir, s, "code.json"))) : [];
8290
+ const dir = join43(root, ".jig", "critique");
8291
+ const surfaces = existsSync27(dir) ? readdirSync16(dir).filter((s) => existsSync27(join43(dir, s, "screen.json")) || existsSync27(join43(dir, s, "code.json"))) : [];
7825
8292
  for (const surface2 of surfaces) {
7826
8293
  const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
7827
8294
  if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
@@ -7832,7 +8299,7 @@ function commandProblems(root, command, surface, start, owner = "") {
7832
8299
  }
7833
8300
  }
7834
8301
  const own = specFor(root, surface)?.slug;
7835
- if (own && surfaces.includes(own)) problems.push(...reportOmissions(join42(dir, own), own));
8302
+ if (own && surfaces.includes(own)) problems.push(...reportOmissions(join43(dir, own), own));
7836
8303
  if (surfaces.length === 0) {
7837
8304
  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.");
7838
8305
  }
@@ -7845,7 +8312,7 @@ function drawingBeforeAsking(root, surface) {
7845
8312
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7846
8313
  const named = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
7847
8314
  const at = named || `.jig/mockups/${spec.slug}.html`;
7848
- if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync26(join42(root, at))) return [];
8315
+ if (/^https?:/i.test(at) || !/\.html?$/i.test(at) || !existsSync27(join43(root, at))) return [];
7849
8316
  const found = mockupDrawingProblems(root, spec.body, at);
7850
8317
  return found.length ? [`Fix the drawing before you put it to the owner; they approve what the spec lists, drawn. ${found.join(" ")}`] : [];
7851
8318
  }
@@ -7853,14 +8320,14 @@ function specChecked(root, spec, start, owner) {
7853
8320
  const found = decisionsFile(root);
7854
8321
  let decisions = "";
7855
8322
  try {
7856
- if (found) decisions = readFileSync35(join42(root, found), "utf8");
8323
+ if (found) decisions = readText(join43(root, found));
7857
8324
  } catch {
7858
8325
  }
7859
8326
  return specCheckProblems(root, spec, fileAtSessionStart(root, spec.path, start), owner, decisions);
7860
8327
  }
7861
8328
  function modeFiles(root) {
7862
8329
  try {
7863
- const state = JSON.parse(readFileSync35(join42(root, ".jig", "state.json"), "utf8"));
8330
+ const state = JSON.parse(readFileSync36(join43(root, ".jig", "state.json"), "utf8"));
7864
8331
  return Object.keys(state.files ?? {}).filter((f) => /(^|\/)mode\.[\w-]+\.css$/.test(f));
7865
8332
  } catch {
7866
8333
  return [];
@@ -7869,7 +8336,7 @@ function modeFiles(root) {
7869
8336
  function reportOmissions(dir, surface) {
7870
8337
  let report2;
7871
8338
  try {
7872
- report2 = readFileSync35(join42(dir, "REPORT.md"), "utf8").toLowerCase();
8339
+ report2 = readText(join43(dir, "REPORT.md")).toLowerCase();
7873
8340
  } catch {
7874
8341
  return [];
7875
8342
  }
@@ -7877,7 +8344,7 @@ function reportOmissions(dir, surface) {
7877
8344
  for (const f of VERDICT_FILES) {
7878
8345
  let file;
7879
8346
  try {
7880
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8347
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
7881
8348
  } catch {
7882
8349
  continue;
7883
8350
  }
@@ -7890,7 +8357,7 @@ function reportOmissions(dir, surface) {
7890
8357
  if (!missing.length) return [];
7891
8358
  return [`.jig/critique/${surface}/REPORT.md leaves out ${missing.length === 1 ? "a finding" : `${missing.length} findings`} the verdict files hold: ${missing.slice(0, 8).join(", ")}. The report is what the owner and \`make\` read; name every finding in it, by id (a decision by its name).`];
7892
8359
  }
7893
- function tweakProblems(root, spec, owner = "") {
8360
+ function tweakProblems(root, spec, owner = "", start) {
7894
8361
  const problems = [];
7895
8362
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
7896
8363
  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\`.`);
@@ -7898,21 +8365,31 @@ function tweakProblems(root, spec, owner = "") {
7898
8365
  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\`.`);
7899
8366
  else problems.push(...structureSinceApproval(root, spec, front));
7900
8367
  const surface = spec.slug;
7901
- const record = readTweak(join42(root, ".jig", "critique", surface));
8368
+ const record = readTweak(join43(root, ".jig", "critique", surface));
7902
8369
  if (!record) {
7903
8370
  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\`).`);
7904
8371
  } else {
7905
8372
  if (!record.change) problems.push(`.jig/critique/${surface}/tweak.json has no \`change\`: the owner's words for what changed.`);
8373
+ 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)) {
8374
+ 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.`);
8375
+ }
8376
+ try {
8377
+ const raw = JSON.parse(readFileSync36(join43(root, ".jig", "critique", surface, "tweak.json"), "utf8"));
8378
+ const extra = Object.keys(raw).filter((k) => !["at", "change", "ids", "deferred"].includes(k));
8379
+ 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.`);
8380
+ } catch {
8381
+ }
7906
8382
  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.`);
7907
8383
  if (record.deferred !== void 0) {
7908
- if (typeof record.deferred === "string" ? !record.deferred.trim() || !quoteHeld(record.deferred.replace(/^["“]|["”]$/g, ""), owner) : !critiqueAtShip(root, spec.body)) {
8384
+ const said = typeof record.deferred === "string" ? ownerWordProblem("defer", record.deferred, owner, `.jig/critique/${surface}/tweak.json: \`deferred\``) : void 0;
8385
+ if (typeof record.deferred === "string" ? said !== void 0 : !critiqueAtShip(root, spec.body)) {
7909
8386
  problems.push(
7910
- `.jig/critique/${surface}/tweak.json defers its re-judge, and nobody said to. \`deferred\` quotes the owner telling you to leave it for later, or is \`true\` where the page's spec says \`critique: at-ship\` (or, the spec silent, jig.config.json says \`"critique": "at-ship"\`). Otherwise re-judge what it names.`
8387
+ `.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}` : "")
7911
8388
  );
7912
8389
  }
7913
8390
  return problems;
7914
8391
  }
7915
- const unjudged = unjudgedTweakIds(join42(root, ".jig", "critique", surface), record);
8392
+ const unjudged = unjudgedTweakIds(join43(root, ".jig", "critique", surface), record);
7916
8393
  if (unjudged.length) {
7917
8394
  problems.push(
7918
8395
  `.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.`
@@ -7939,7 +8416,7 @@ function structureSinceApproval(root, spec, front) {
7939
8416
  const problems = [];
7940
8417
  let thenBody = "";
7941
8418
  try {
7942
- thenBody = git(["show", `${baseline}:./${spec.path}`]);
8419
+ thenBody = lf(git(["show", `${baseline}:./${spec.path}`]));
7943
8420
  } catch {
7944
8421
  return [];
7945
8422
  }
@@ -7948,9 +8425,9 @@ function structureSinceApproval(root, spec, front) {
7948
8425
  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\`.`);
7949
8426
  }
7950
8427
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7951
- if (at && !/^https?:/i.test(at) && existsSync26(join42(root, at))) {
8428
+ if (at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
7952
8429
  try {
7953
- if (git(["show", `${baseline}:./${at}`]) !== readFileSync35(join42(root, at), "utf8")) {
8430
+ if (lf(git(["show", `${baseline}:./${at}`])) !== readText(join43(root, at))) {
7954
8431
  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\`.`);
7955
8432
  }
7956
8433
  } catch {
@@ -7960,7 +8437,7 @@ function structureSinceApproval(root, spec, front) {
7960
8437
  }
7961
8438
  function readTweak(dir) {
7962
8439
  try {
7963
- const raw = JSON.parse(readFileSync35(join42(dir, "tweak.json"), "utf8"));
8440
+ const raw = JSON.parse(readFileSync36(join43(dir, "tweak.json"), "utf8"));
7964
8441
  const deferred = typeof raw.deferred === "string" || raw.deferred === true ? raw.deferred : void 0;
7965
8442
  return { at: String(raw.at ?? ""), change: String(raw.change ?? "").trim(), ids: Array.isArray(raw.ids) ? raw.ids.map(String) : [], ...deferred !== void 0 ? { deferred } : {} };
7966
8443
  } catch {
@@ -7973,20 +8450,20 @@ function surfacePage(projectRoot, surface) {
7973
8450
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
7974
8451
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
7975
8452
  for (const candidate of candidates) {
7976
- if (candidate && existsSync26(join42(projectRoot, candidate))) return candidate;
8453
+ if (candidate && existsSync27(join43(projectRoot, candidate))) return candidate;
7977
8454
  }
7978
8455
  return void 0;
7979
8456
  }
7980
8457
  function gate(opts) {
7981
8458
  const root = opts.projectRoot;
7982
- if (!existsSync26(join42(root, "jig.config.json")) && !existsSync26(join42(root, ".jig"))) {
8459
+ if (!existsSync27(join43(root, "jig.config.json")) && !existsSync27(join43(root, ".jig"))) {
7983
8460
  return { block: false, reason: "" };
7984
8461
  }
7985
8462
  const invocation = lastJigInvocation(opts.input.transcript_path);
7986
8463
  const command = invocation?.command;
7987
8464
  const waiting = command !== void 0 && ASKS_THE_OWNER.has(command) && asksOwner(lastAssistantText(opts.input.transcript_path));
7988
8465
  const start = sessionStart(opts.input.transcript_path);
7989
- const owner = command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
8466
+ const owner = command === "decide" || command === "spec" || command === "mockup" || command === "make" || command === "tweak" ? ownerWords(opts.input.transcript_path) : "";
7990
8467
  const problems = command && !waiting ? commandProblems(root, command, invocation?.surface, start, owner).map((p) => `/jig ${command}: ${p}`) : [];
7991
8468
  if (waiting && command === "mockup") problems.push(...drawingBeforeAsking(root, invocation?.surface).map((p) => `/jig mockup: ${p}`));
7992
8469
  if (command === "ship" && !asksOwner(lastAssistantText(opts.input.transcript_path))) {
@@ -8000,9 +8477,24 @@ ${result.report}`);
8000
8477
  problems.push(`/jig make: ${spec.path} still says \`mockup: pending\`: nobody has said whether to draw it. Ask the owner, draw it with \`/jig mockup\` or skip it; if they skip it, write \`mockup: skipped \u2014 "<their words>"\` and build from the spec alone.`);
8001
8478
  }
8002
8479
  }
8480
+ if (command === "spec" && !waiting) {
8481
+ const spec = namedSpec(root, invocation?.surface);
8482
+ if (spec && isConfirmed(spec.body) && !isConfirmed(fileAtSessionStart(root, spec.path, start))) {
8483
+ const reply = ownerReplyAfter(opts.input.transcript_path, /\bconfirm/i);
8484
+ const said = reply === void 0 ? void 0 : ownerWordProblem("confirm", reply.slice(0, 400), reply, `${spec.path}: \`confirmed: true\``);
8485
+ 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.`);
8486
+ 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.`);
8487
+ }
8488
+ }
8003
8489
  if (waiting && command === "spec") {
8004
- const spec = specFor(root, invocation?.surface);
8490
+ const spec = namedSpec(root, invocation?.surface);
8491
+ if (!spec && invocation?.surface && /\bconfirm/i.test(lastAssistantText(opts.input.transcript_path) ?? "")) {
8492
+ 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.`);
8493
+ }
8005
8494
  if (spec) problems.push(...specChecked(root, spec, start, owner).map((p) => `/jig spec: ${p}`));
8495
+ if (spec && existsSync27(join43(root, ".jig", "specs", `${spec.slug}.checked.json`))) {
8496
+ problems.push(...[...specProblems(spec), ...newFieldProblems(spec, fileAtSessionStart(root, spec.path, start))].map((p) => `/jig spec: ${p}`));
8497
+ }
8006
8498
  }
8007
8499
  const selection = selectFiles(root, false);
8008
8500
  const changedUi = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f) || isReaderText(f));
@@ -8025,11 +8517,17 @@ ${result.report}`);
8025
8517
  }
8026
8518
  }
8027
8519
  problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
8028
- const critiqueDir = join42(root, ".jig", "critique");
8029
- if (existsSync26(critiqueDir)) {
8520
+ problems.push(...probesLeftBehind(root, surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)));
8521
+ const touched = ["AGENTS.md", "CLAUDE.md"].some((f) => {
8522
+ const now = existsSync27(join43(root, f)) ? readText(join43(root, f)) : "";
8523
+ return fileAtSessionStart(root, f, start) !== now;
8524
+ });
8525
+ if (touched) problems.push(...agentFileProblems(root).map((p) => `Jig's agent instructions: ${p}`));
8526
+ const critiqueDir = join43(root, ".jig", "critique");
8527
+ if (existsSync27(critiqueDir)) {
8030
8528
  for (const surface of surfacesInPlay(root, command, opts.input.transcript_path, invocation?.surface)) {
8031
- const dir = join42(critiqueDir, surface);
8032
- if (!existsSync26(join42(dir, "screen.json")) && !existsSync26(join42(dir, "code.json"))) continue;
8529
+ const dir = join43(critiqueDir, surface);
8530
+ if (!existsSync27(join43(dir, "screen.json")) && !existsSync27(join43(dir, "code.json"))) continue;
8033
8531
  const v = verifyVerdicts({ projectRoot: root, surface });
8034
8532
  if (!v.ok) {
8035
8533
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -8052,10 +8550,10 @@ ${problems.join("\n\n")}`
8052
8550
  }
8053
8551
  const session = opts.input.session_id ?? "unknown";
8054
8552
  const key = `${session}:${createHash3("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
8055
- const stateFile = join42(root, ".jig", "gate.json");
8553
+ const stateFile = join43(root, ".jig", "gate.json");
8056
8554
  let state = {};
8057
8555
  try {
8058
- state = JSON.parse(readFileSync35(stateFile, "utf8"));
8556
+ state = JSON.parse(readFileSync36(stateFile, "utf8"));
8059
8557
  } catch {
8060
8558
  }
8061
8559
  if (problems.length === 0) {
@@ -8090,7 +8588,7 @@ function verdictDigests(dir) {
8090
8588
  for (const f of VERDICT_FILES) {
8091
8589
  let file;
8092
8590
  try {
8093
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8591
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
8094
8592
  } catch {
8095
8593
  continue;
8096
8594
  }
@@ -8105,7 +8603,7 @@ function writeLock(root, surface, lockPath, dir, now) {
8105
8603
  const pageFile2 = recordedPage(root, surface);
8106
8604
  let page;
8107
8605
  try {
8108
- if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync35(join42(root, pageFile2), "utf8")) };
8606
+ if (pageFile2) page = { file: pageFile2, checksum: pageChecksum(readFileSync36(join43(root, pageFile2), "utf8")) };
8109
8607
  } catch {
8110
8608
  }
8111
8609
  const tweakAt = readTweak(dir)?.at;
@@ -8118,7 +8616,7 @@ function verdictChecksum(dir) {
8118
8616
  const parts = VERDICT_FILES.map((f) => {
8119
8617
  try {
8120
8618
  return `${f}
8121
- ${readFileSync35(join42(dir, f), "utf8")}`;
8619
+ ${readFileSync36(join43(dir, f), "utf8")}`;
8122
8620
  } catch {
8123
8621
  return "";
8124
8622
  }
@@ -8126,15 +8624,15 @@ ${readFileSync35(join42(dir, f), "utf8")}`;
8126
8624
  return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
8127
8625
  }
8128
8626
  function verdictGuard(root, command, inPlay) {
8129
- const critiqueDir = join42(root, ".jig", "critique");
8130
- if (!existsSync26(critiqueDir)) return [];
8627
+ const critiqueDir = join43(root, ".jig", "critique");
8628
+ if (!existsSync27(critiqueDir)) return [];
8131
8629
  const problems = [];
8132
8630
  for (const surface of readdirSync16(critiqueDir)) {
8133
8631
  if (surface.startsWith("_")) continue;
8134
- const dir = join42(critiqueDir, surface);
8632
+ const dir = join43(critiqueDir, surface);
8135
8633
  const now = verdictChecksum(dir);
8136
8634
  if (!now) continue;
8137
- const lockPath = join42(dir, LOCK);
8635
+ const lockPath = join43(dir, LOCK);
8138
8636
  if ((command === "critique" || command === "ship") && (!inPlay || inPlay.includes(surface))) {
8139
8637
  writeLock(root, surface, lockPath, dir, now);
8140
8638
  problems.push(...lockLeftBehind(root, surface));
@@ -8142,7 +8640,7 @@ function verdictGuard(root, command, inPlay) {
8142
8640
  }
8143
8641
  let lock = {};
8144
8642
  try {
8145
- lock = JSON.parse(readFileSync35(lockPath, "utf8"));
8643
+ lock = JSON.parse(readFileSync36(lockPath, "utf8"));
8146
8644
  } catch {
8147
8645
  continue;
8148
8646
  }
@@ -8175,6 +8673,25 @@ function lockLeftBehind(root, surface) {
8175
8673
  }
8176
8674
  return [`${rel}/${LOCK} was written when you stopped, after the verdicts it records were committed. Commit it on its own (\`git add ${rel}/${LOCK} && git commit -m "chore: record ${surface}'s verdict lock"\`), and nothing else.`];
8177
8675
  }
8676
+ function probesLeftBehind(root, surfaces) {
8677
+ if (!surfaces.length) return [];
8678
+ let lines;
8679
+ try {
8680
+ lines = execFileSync4("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
8681
+ } catch {
8682
+ return [];
8683
+ }
8684
+ const path = (l) => l.slice(3).replace(/^"|"$/g, "");
8685
+ const isProbe = (p) => /^\.jig\/critique\/[^/]+\/probe-[^/]+\.json$/.test(p);
8686
+ const isLock = (p) => /^\.jig\/critique\/[^/]+\/verdicts\.lock$/.test(p);
8687
+ if (lines.some((l) => !isProbe(path(l)) && !isLock(path(l)))) return [];
8688
+ const left = lines.map(path).filter((p) => isProbe(p) && surfaces.some((s) => p.startsWith(`.jig/critique/${s}/`)));
8689
+ if (!left.length) return [];
8690
+ const bySurface = [...new Set(left.map((p) => p.split("/")[2]))];
8691
+ return [
8692
+ `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.`
8693
+ ];
8694
+ }
8178
8695
  function tweakVerdictProblems(dir, surface, lockedVerdicts) {
8179
8696
  const record = readTweak(dir);
8180
8697
  if (!record) {
@@ -8199,7 +8716,7 @@ function unjudgedTweakIds(dir, record) {
8199
8716
  for (const f of VERDICT_FILES) {
8200
8717
  let file;
8201
8718
  try {
8202
- file = JSON.parse(readFileSync35(join42(dir, f), "utf8"));
8719
+ file = JSON.parse(readFileSync36(join43(dir, f), "utf8"));
8203
8720
  } catch {
8204
8721
  continue;
8205
8722
  }
@@ -8211,7 +8728,7 @@ function unjudgedTweakIds(dir, record) {
8211
8728
  }
8212
8729
  function save(file, state) {
8213
8730
  try {
8214
- mkdirSync8(join42(file, ".."), { recursive: true });
8731
+ mkdirSync8(join43(file, ".."), { recursive: true });
8215
8732
  writeFileSync6(file, JSON.stringify(state), "utf8");
8216
8733
  } catch {
8217
8734
  }
@@ -8219,7 +8736,7 @@ function save(file, state) {
8219
8736
 
8220
8737
  // src/index.ts
8221
8738
  var packageRoot = getPackageRoot();
8222
- var { version } = JSON.parse(readFileSync36(join43(packageRoot, "package.json"), "utf8"));
8739
+ var { version } = JSON.parse(readFileSync37(join44(packageRoot, "package.json"), "utf8"));
8223
8740
  function warnIfUnpublishedPin() {
8224
8741
  if (isDevVersion(version)) {
8225
8742
  console.warn(
@@ -8263,7 +8780,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8263
8780
  projectRoot,
8264
8781
  packageRoot: assetRoot(),
8265
8782
  version,
8266
- homeDir: homedir2(),
8783
+ homeDir: homedir3(),
8267
8784
  hook
8268
8785
  });
8269
8786
  if (result.warning) {
@@ -8272,6 +8789,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8272
8789
  }
8273
8790
  if (result.hookOnly) {
8274
8791
  console.log(result.hookOnly);
8792
+ for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
8275
8793
  if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
8276
8794
  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.");
8277
8795
  return;
@@ -8280,6 +8798,7 @@ program.command("install").description("Install Jig rules and the agent skill fi
8280
8798
  warnIfUnpublishedPin();
8281
8799
  for (const f of result.written) console.log(` + ${f}`);
8282
8800
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
8801
+ for (const f of result.agentFiles ?? []) console.log(` + ${f} (Jig's block for your agent; your own text in it is untouched)`);
8283
8802
  if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
8284
8803
  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.");
8285
8804
  if (result.stopHook === void 0 && opts.agent === "claude" && opts.scope === "project") {
@@ -8307,7 +8826,7 @@ program.command("update").description("Update vendored Jig rules, skipping files
8307
8826
  projectRoot,
8308
8827
  packageRoot: assetRoot(),
8309
8828
  version,
8310
- homeDir: homedir2()
8829
+ homeDir: homedir3()
8311
8830
  });
8312
8831
  const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
8313
8832
  warnIfUnpublishedPin();
@@ -8326,6 +8845,16 @@ program.command("update").description("Update vendored Jig rules, skipping files
8326
8845
  process.exit(1);
8327
8846
  }
8328
8847
  });
8848
+ 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) => {
8849
+ const projectRoot = findProjectRoot(process.cwd());
8850
+ const slug = name.replace(/^.*\//, "").replace(/\.spec\.md$/, "");
8851
+ const path = join44(projectRoot, ".jig", "specs", `${slug}.spec.md`);
8852
+ if (!existsSync28(path)) {
8853
+ console.error(` \u2717 no spec at .jig/specs/${slug}.spec.md`);
8854
+ process.exit(1);
8855
+ }
8856
+ console.log(specChecksum(readFileSync37(path, "utf8")));
8857
+ });
8329
8858
  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) => {
8330
8859
  const projectRoot = findProjectRoot(process.cwd());
8331
8860
  try {
@@ -8370,7 +8899,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
8370
8899
  try {
8371
8900
  const result = check({
8372
8901
  projectRoot,
8373
- homeDir: homedir2(),
8902
+ homeDir: homedir3(),
8374
8903
  version,
8375
8904
  all: opts.all,
8376
8905
  ci: opts.ci
@@ -8453,13 +8982,14 @@ program.command("probe").description("Print the render probe. With --save, read
8453
8982
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
8454
8983
  let input = {};
8455
8984
  try {
8456
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync36(0, "utf8") || "{}");
8985
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync37(0, "utf8") || "{}");
8457
8986
  } catch {
8458
8987
  }
8459
8988
  const cwd = input.cwd ?? process.cwd();
8460
8989
  try {
8461
8990
  const projectRoot = findProjectRoot(cwd);
8462
- const critiqued = critiquedSurfaces(projectRoot);
8991
+ const byHand = !input.transcript_path;
8992
+ const critiqued = byHand ? [] : critiquedSurfaces(projectRoot);
8463
8993
  for (const surface of surfacesToProbe(projectRoot, input).filter((s) => critiqued.includes(s))) {
8464
8994
  const page = surfacePage(projectRoot, surface) ?? recordedPage(projectRoot, surface);
8465
8995
  if (!page) continue;
@@ -8482,7 +9012,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8482
9012
  await init({
8483
9013
  projectRoot,
8484
9014
  packageRoot: assetRoot(),
8485
- homeDir: homedir2(),
9015
+ homeDir: homedir3(),
8486
9016
  version,
8487
9017
  yes: opts.yes
8488
9018
  });
@@ -8493,7 +9023,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
8493
9023
  });
8494
9024
  function resolveSurfaceMode(projectRoot) {
8495
9025
  try {
8496
- const config = JSON.parse(readFileSync36(join43(projectRoot, "jig.config.json"), "utf8"));
9026
+ const config = JSON.parse(readFileSync37(join44(projectRoot, "jig.config.json"), "utf8"));
8497
9027
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
8498
9028
  return modes.length === 1 ? modes[0] : void 0;
8499
9029
  } catch {