jig-ui 0.3.0 → 0.4.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 readFileSync12 } from "fs";
5
+ import { readFileSync as readFileSync16 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join14 } from "path";
7
+ import { join as join21 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -37,12 +37,16 @@ function assetRoot(startDir = getPackageRoot()) {
37
37
  current = dirname(current);
38
38
  }
39
39
  }
40
+ function isPublishedBuild(packageRoot2) {
41
+ return packageRoot2.split(/[\\/]/).includes("node_modules");
42
+ }
40
43
 
41
44
  // src/commands/install.ts
42
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, writeFileSync as writeFileSync2 } from "fs";
43
- import { dirname as dirname3, join as join3 } from "path";
45
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
46
+ import { dirname as dirname4, join as join6 } from "path";
44
47
 
45
48
  // src/adapters/types.ts
49
+ var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, review the UI against the rules, or refresh the install.";
46
50
  var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
47
51
  function assertSafeRelPath(relPath, adapterName) {
48
52
  const isAbsolute = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
@@ -56,92 +60,172 @@ function quoteYamlString(value) {
56
60
  }
57
61
  var BLOCK_START = "<!-- jig:start -->";
58
62
  var BLOCK_END = "<!-- jig:end -->";
59
- function agentsBlock(skillBody) {
60
- return [BLOCK_START, "", "# Jig \u2014 UI rules", "", skillBody, "", BLOCK_END, ""].join("\n");
63
+ function agentsPointer(skillPath) {
64
+ return [
65
+ BLOCK_START,
66
+ "",
67
+ "# Jig \u2014 UI rules",
68
+ "",
69
+ `This project uses Jig, a design system for generating and reviewing UI.`,
70
+ `Before building or reviewing any interface, read \`${skillPath}\` and follow it.`,
71
+ "It is short, and it tells you which rule files to load for the task at hand.",
72
+ "",
73
+ BLOCK_END,
74
+ ""
75
+ ].join("\n");
61
76
  }
62
77
 
63
- // src/adapters/claude.ts
64
- var claude = {
65
- name: "claude",
66
- displayName: "Claude Code",
67
- supportsScope: () => true,
68
- skillFiles(ctx) {
69
- const content = [
70
- "---",
71
- "name: jig",
72
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
73
- "user-invocable: true",
74
- "---",
75
- "",
76
- ctx.skillBody,
77
- ""
78
- ].join("\n");
79
- return [{ relPath: ".claude/skills/jig/SKILL.md", content }];
80
- }
81
- };
82
-
83
78
  // src/adapters/codex.ts
84
79
  var codex = {
85
80
  name: "codex",
86
81
  displayName: "Codex",
87
- supportsScope: () => true,
88
- skillFiles(ctx) {
89
- const relPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
90
- return [{ relPath, content: agentsBlock(ctx.skillBody) }];
91
- }
92
- };
93
-
94
- // src/adapters/cursor.ts
95
- var cursor = {
96
- name: "cursor",
97
- displayName: "Cursor",
98
- supportsScope: (scope) => scope === "project",
99
- skillFiles(ctx) {
100
- const content = [
101
- "---",
102
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
103
- "alwaysApply: false",
104
- "---",
105
- "",
106
- ctx.skillBody,
107
- ""
108
- ].join("\n");
109
- return [{ relPath: ".cursor/rules/jig.mdc", content }];
110
- }
111
- };
112
-
113
- // src/adapters/opencode.ts
114
- var opencode = {
115
- name: "opencode",
116
- displayName: "opencode",
117
- supportsScope: () => true,
118
82
  skillFiles(ctx) {
119
- const content = [
120
- "---",
121
- "name: jig",
122
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
123
- "---",
124
- "",
125
- ctx.skillBody,
126
- ""
127
- ].join("\n");
128
- const relPath = ctx.scope === "global" ? ".config/opencode/skills/jig/SKILL.md" : ".opencode/skills/jig/SKILL.md";
129
- return [{ relPath, content }];
130
- }
83
+ const skillPath = ".agents/skills/jig/SKILL.md";
84
+ const files = [{ relPath: skillPath, content: ctx.skillBody }];
85
+ const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
86
+ files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
87
+ if (ctx.commandBody && ctx.scope === "global") {
88
+ files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
89
+ ` });
90
+ }
91
+ return files;
92
+ },
93
+ /**
94
+ * codex has no skill directory: AGENTS.md is a plain file (project root, or
95
+ * `.codex/` for a global install). Reference material lives under `.codex/`
96
+ * at BOTH scopes.
97
+ *
98
+ * Project scope used to be a bare `.jig`, which put Jig's rules, index,
99
+ * licence and manifest into the one directory 0.4.0 reserves for the
100
+ * project's own material — the brand file, the mode copies, `state.json`.
101
+ * That cost more than tidiness:
102
+ *
103
+ * - `detectLegacyRules` scans `.jig/` for install artifacts and offers to
104
+ * remove them. Under the old layout a *live* codex install looked exactly
105
+ * like the legacy one, so the migration path could offer to delete the
106
+ * install just made.
107
+ * - One directory holding two manifests is what let a stale
108
+ * `.jig/manifest.json` hijack `update` and resurrect the vendored layout.
109
+ *
110
+ * Mirroring the global path keeps codex's own convention and leaves `.jig/`
111
+ * unambiguously the project's.
112
+ */
113
+ /**
114
+ * Beside the skill, like every other harness — the rules are what `SKILL.md`
115
+ * tells the agent to read, so they belong next to it. This used to be
116
+ * `.codex/.jig`, and before that a bare `.jig`, which put Jig's property in
117
+ * the one directory the project owns.
118
+ */
119
+ referenceDir: () => ".agents/skills/jig",
120
+ argsPlaceholder: "$ARGUMENTS"
131
121
  };
132
122
 
133
- // src/adapters/generic.ts
134
- var generic = {
135
- name: "generic",
136
- displayName: "Generic (AGENTS.md)",
137
- supportsScope: (scope) => scope === "project",
138
- skillFiles(ctx) {
139
- return [{ relPath: "AGENTS.md", content: agentsBlock(ctx.skillBody) }];
123
+ // src/adapters/skill-dir.ts
124
+ var SKILL_DIR_HARNESSES = [
125
+ {
126
+ name: "claude",
127
+ dir: ".claude",
128
+ displayName: "Claude Code",
129
+ extraFrontmatter: ["user-invocable: true"],
130
+ // `commands/<name>.md`, YAML frontmatter, `$ARGUMENTS` in the body —
131
+ // verified against the shipped command files under
132
+ // ~/.claude/plugins/marketplaces/*/plugins/*/commands/.
133
+ commands: { dir: "commands", format: "md" }
134
+ },
135
+ { name: "cursor", dir: ".cursor", displayName: "Cursor", commands: { dir: "commands", format: "md" } },
136
+ {
137
+ name: "opencode",
138
+ dir: ".opencode",
139
+ globalDir: ".config/opencode",
140
+ displayName: "opencode",
141
+ // Singular `command/`, unlike everyone else's `commands/`.
142
+ commands: { dir: "command", format: "md" }
143
+ },
144
+ // No slash-command mechanism of its own — `.agents` is a skills convention
145
+ // rather than a harness, so there is nothing to write a command file for.
146
+ { name: "generic", dir: ".agents", displayName: "Generic (.agents/skills)" },
147
+ // Proves the table design: this row is the entire diff needed to support
148
+ // a sixth harness. Gemini CLI, Copilot CLI and others read `.agents/skills`
149
+ // too, but Gemini also has its own `.gemini` skill directory.
150
+ {
151
+ name: "gemini",
152
+ dir: ".gemini",
153
+ displayName: "Gemini CLI",
154
+ // Gemini reads TOML, with `{{args}}` where the others use `$ARGUMENTS`.
155
+ commands: { dir: "commands", format: "toml" }
140
156
  }
141
- };
157
+ ];
158
+ function dirFor(h, scope) {
159
+ return scope === "global" && h.globalDir ? h.globalDir : h.dir;
160
+ }
161
+ function skillDirAdapter(h) {
162
+ return {
163
+ name: h.name,
164
+ displayName: h.displayName,
165
+ skillFiles(ctx) {
166
+ const content = [
167
+ "---",
168
+ "name: jig",
169
+ `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
170
+ ...h.extraFrontmatter ?? [],
171
+ "---",
172
+ "",
173
+ ctx.skillBody,
174
+ ""
175
+ ].join("\n");
176
+ const files = [
177
+ { relPath: `${dirFor(h, ctx.scope)}/skills/jig/SKILL.md`, content }
178
+ ];
179
+ if (h.commands && ctx.commandBody) {
180
+ files.push({
181
+ relPath: `${dirFor(h, ctx.scope)}/${h.commands.dir}/jig.${h.commands.format}`,
182
+ content: h.commands.format === "toml" ? tomlCommand(ctx.commandBody) : markdownCommand(ctx.commandBody, ctx.subcommands ?? [])
183
+ });
184
+ }
185
+ return files;
186
+ },
187
+ referenceDir(scope) {
188
+ return `${dirFor(h, scope)}/skills/jig`;
189
+ },
190
+ argsPlaceholder: h.commands?.format === "toml" ? "{{args}}" : "$ARGUMENTS"
191
+ };
192
+ }
193
+ function markdownCommand(body, subcommands) {
194
+ return [
195
+ "---",
196
+ `description: ${quoteYamlString(COMMAND_DESCRIPTION)}`,
197
+ `argument-hint: ${subcommands.join("|")} [flags]`,
198
+ "---",
199
+ "",
200
+ body,
201
+ ""
202
+ ].join("\n");
203
+ }
204
+ var TOML_DELIM = "'''";
205
+ function tomlCommand(body) {
206
+ const safe = body.split(TOML_DELIM).join('"""');
207
+ return [
208
+ `description = ${JSON.stringify(COMMAND_DESCRIPTION)}`,
209
+ `prompt = ${TOML_DELIM}`,
210
+ safe,
211
+ TOML_DELIM,
212
+ ""
213
+ ].join("\n");
214
+ }
215
+ var SKILL_DIR_ADAPTERS = SKILL_DIR_HARNESSES.map(skillDirAdapter);
142
216
 
143
217
  // src/adapters/registry.ts
144
- var ADAPTERS = [claude, codex, cursor, opencode, generic];
218
+ var ADAPTERS = [codex, ...SKILL_DIR_ADAPTERS];
219
+ function referenceDirFor(adapter, scope) {
220
+ const dir = adapter.referenceDir(scope);
221
+ assertSafeRelPath(dir, adapter.name);
222
+ return dir;
223
+ }
224
+ for (const adapter of ADAPTERS) {
225
+ for (const scope of ["project", "global"]) {
226
+ assertSafeRelPath(adapter.referenceDir(scope), adapter.name);
227
+ }
228
+ }
145
229
  function adapterNames() {
146
230
  return ADAPTERS.map((a) => a.name);
147
231
  }
@@ -160,12 +244,59 @@ function skillFilesFor(adapter, ctx) {
160
244
  return files;
161
245
  }
162
246
 
247
+ // src/install/references.ts
248
+ import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
249
+ import { join as join2, posix } from "path";
250
+ function referenceFiles(packageRoot2) {
251
+ const root = join2(packageRoot2, "references");
252
+ if (!existsSync2(root)) return [];
253
+ const found = [];
254
+ const walk2 = (dir, prefix) => {
255
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
256
+ (a, b) => a.name.localeCompare(b.name)
257
+ )) {
258
+ const child = join2(dir, entry.name);
259
+ const rel = prefix ? posix.join(prefix, entry.name) : entry.name;
260
+ if (entry.isDirectory()) {
261
+ walk2(child, rel);
262
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
263
+ found.push({ relPath: rel, content: readFileSync(child, "utf8") });
264
+ }
265
+ }
266
+ };
267
+ walk2(root, "");
268
+ return found;
269
+ }
270
+
163
271
  // src/install/manifest.ts
164
272
  import { createHash } from "crypto";
165
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
166
- import { dirname as dirname2, join as join2 } from "path";
167
- var MANIFEST_REL = join2(".jig", "manifest.json");
273
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
274
+ import { dirname as dirname3, join as join4 } from "path";
275
+
276
+ // src/install/atomic.ts
277
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
278
+ import { dirname as dirname2, join as join3 } from "path";
279
+ function writeFileAtomic(path, content) {
280
+ const dir = dirname2(path);
281
+ mkdirSync(dir, { recursive: true });
282
+ const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
283
+ try {
284
+ writeFileSync(tmp, content, "utf8");
285
+ renameSync(tmp, path);
286
+ } catch (err) {
287
+ try {
288
+ unlinkSync(tmp);
289
+ } catch {
290
+ }
291
+ throw err;
292
+ }
293
+ }
294
+
295
+ // src/install/manifest.ts
168
296
  var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
297
+ function manifestPath(root, manifestDir) {
298
+ return join4(root, ...manifestDir.split("/"), "manifest.json");
299
+ }
169
300
  function checksum(content) {
170
301
  const normalized = content.replace(/\r\n/g, "\n");
171
302
  return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex")}`;
@@ -200,29 +331,45 @@ function validateManifest(raw, path) {
200
331
  files
201
332
  };
202
333
  }
203
- function readManifest(projectRoot) {
204
- const path = join2(projectRoot, MANIFEST_REL);
205
- if (!existsSync2(path)) return null;
334
+ function readManifest(root, manifestDir = ".jig") {
335
+ const path = manifestPath(root, manifestDir);
336
+ if (!existsSync3(path)) return null;
206
337
  let raw;
207
338
  try {
208
- raw = JSON.parse(readFileSync(path, "utf8"));
339
+ raw = JSON.parse(readFileSync2(path, "utf8"));
209
340
  } catch {
210
341
  throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
211
342
  }
212
343
  return validateManifest(raw, path);
213
344
  }
214
- function writeManifest(projectRoot, m) {
215
- const path = join2(projectRoot, MANIFEST_REL);
216
- mkdirSync(dirname2(path), { recursive: true });
217
- writeFileSync(path, `${JSON.stringify(m, null, 2)}
218
- `, "utf8");
345
+ var MERGE_ATTEMPTS = 5;
346
+ function writeManifest(root, m, manifestDir = ".jig") {
347
+ const path = manifestPath(root, manifestDir);
348
+ mkdirSync2(dirname3(path), { recursive: true });
349
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
350
+ const onDisk = readManifestQuietly(path);
351
+ const merged = { ...m, files: { ...onDisk?.files ?? {}, ...m.files } };
352
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
353
+ `);
354
+ const after = readManifestQuietly(path);
355
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
356
+ }
357
+ }
358
+ function readManifestQuietly(path) {
359
+ try {
360
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
361
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
362
+ return parsed;
363
+ } catch {
364
+ return null;
365
+ }
219
366
  }
220
367
  function isModified(projectRoot, relPath, m) {
221
368
  const recorded = m.files[relPath];
222
369
  if (!recorded) return false;
223
- const abs = join2(projectRoot, relPath);
224
- if (!existsSync2(abs)) return false;
225
- return checksum(readFileSync(abs, "utf8")) !== recorded;
370
+ const abs = join4(projectRoot, relPath);
371
+ if (!existsSync3(abs)) return false;
372
+ return checksum(readFileSync2(abs, "utf8")) !== recorded;
226
373
  }
227
374
 
228
375
  // src/template/render.ts
@@ -250,15 +397,22 @@ function renderCommandTable(metadata) {
250
397
  }
251
398
 
252
399
  // src/install/vendor.ts
400
+ import { posix as posix2 } from "path";
401
+ var { join: join5 } = posix2;
253
402
  var DELIMITERS = {
254
403
  html: ["<!--", "-->"],
255
404
  css: ["/*", "*/"]
256
405
  };
257
- function vendorHeader(file, version2, style = "html") {
406
+ function licencePathFor(relPath) {
407
+ const depth = relPath.split("/").length - 1;
408
+ return depth === 0 ? "." : Array(depth).fill("..").join("/");
409
+ }
410
+ function vendorHeader(file, version2, style = "html", licencePath = ".") {
258
411
  const [open, close] = DELIMITERS[style];
412
+ 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")}.`;
259
413
  return [
260
414
  `${open} ${file} \u2014 vendored from Jig v${version2}.`,
261
- " Licensed Apache-2.0. See .jig/LICENSE and .jig/NOTICE.",
415
+ attribution,
262
416
  ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
263
417
  "",
264
418
  ""
@@ -301,16 +455,16 @@ function stripMarkerRegions(existing) {
301
455
  return { text: existing, insertAt: existing.length, removed: false };
302
456
  }
303
457
  let text = "";
304
- let cursor2 = 0;
458
+ let cursor = 0;
305
459
  let insertAt = -1;
306
460
  regions.forEach((region, i) => {
307
- let chunk = existing.slice(cursor2, region.start);
461
+ let chunk = existing.slice(cursor, region.start);
308
462
  if (i > 0) chunk = chunk.replace(/^\n/, "");
309
463
  text += chunk;
310
464
  if (insertAt === -1) insertAt = text.length;
311
- cursor2 = region.end;
465
+ cursor = region.end;
312
466
  });
313
- const tail = existing.slice(cursor2).replace(/^\n/, "");
467
+ const tail = existing.slice(cursor).replace(/^\n/, "");
314
468
  text += tail;
315
469
  return { text, insertAt, removed: true };
316
470
  }
@@ -323,82 +477,140 @@ function upsertBlock(existing, block) {
323
477
  return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
324
478
  }
325
479
 
480
+ // src/install/line-endings.ts
481
+ function dominantEnding(existing) {
482
+ const crlf = (existing.match(/\r\n/g) ?? []).length;
483
+ const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
484
+ if (crlf === 0 && lf === 0) return "\n";
485
+ return crlf >= lf ? "\r\n" : "\n";
486
+ }
487
+ function matchLineEndings(content, existing) {
488
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
489
+ const target = existing === void 0 ? "\n" : dominantEnding(existing);
490
+ return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
491
+ }
492
+
326
493
  // src/commands/install.ts
327
494
  var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
328
495
  function relKey(...parts) {
329
496
  return parts.join("/");
330
497
  }
331
498
  function writeFile(root, key, content, files) {
332
- const abs = join3(root, ...key.split("/"));
333
- mkdirSync2(dirname3(abs), { recursive: true });
334
- writeFileSync2(abs, content, "utf8");
335
- files[key] = checksum(content);
336
- }
337
- function buildSkillBody(packageRoot2, scope) {
338
- const template = readFileSync2(join3(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
499
+ const abs = join6(root, ...key.split("/"));
500
+ mkdirSync3(dirname4(abs), { recursive: true });
501
+ const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
502
+ const toWrite = matchLineEndings(content, existing);
503
+ writeFileSync3(abs, toWrite, "utf8");
504
+ files[key] = checksum(toWrite);
505
+ }
506
+ function buildSkillBody(packageRoot2, rulesPath, version2) {
507
+ const template = readFileSync3(join6(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
339
508
  const metadata = JSON.parse(
340
- readFileSync2(join3(packageRoot2, "templates", "command-metadata.json"), "utf8")
509
+ readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
341
510
  );
342
511
  return render(template, {
343
512
  command_prefix: "/jig ",
344
- scripts_path: "npx jig-ui",
513
+ scripts_path: version2 ? `npx jig-ui@${version2}` : "npx jig-ui",
514
+ // `update` must NOT carry the pin. Pinned, it refreshes to the version
515
+ // already installed — reporting "Updated Jig → <same version>" for a no-op
516
+ // — so a reader following the skill could never upgrade.
517
+ update_path: "npx jig-ui@latest",
345
518
  ask_instruction: ASK_INSTRUCTION,
346
519
  available_commands: renderCommandTable(metadata),
347
520
  config_file: "jig.config.json",
348
- rules_path: scope === "global" ? "~/.jig" : ".jig"
521
+ rules_path: rulesPath
349
522
  });
350
523
  }
524
+ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
525
+ const metadata = JSON.parse(
526
+ readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
527
+ );
528
+ const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
529
+ const templatePath = join6(packageRoot2, "templates", "COMMAND.md.tmpl");
530
+ if (!existsSync4(templatePath)) return { body: "", subcommands };
531
+ const template = readFileSync3(templatePath, "utf8");
532
+ const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{rules_path\}\}/g, rulesPath);
533
+ return { body, subcommands };
534
+ }
535
+ function rulesPathFor(referenceDir, scope) {
536
+ return scope === "global" ? `~/${referenceDir}/rules` : `${referenceDir}/rules`;
537
+ }
351
538
  function install(opts) {
352
539
  const adapter = getAdapter(opts.agent);
353
- if (!adapter.supportsScope(opts.scope)) {
354
- throw new Error(`Adapter '${adapter.name}' does not support ${opts.scope} scope.`);
540
+ if (opts.scope === "project") {
541
+ const globalReferenceDir = referenceDirFor(adapter, "global");
542
+ let globalManifest;
543
+ try {
544
+ globalManifest = readManifest(opts.homeDir, globalReferenceDir);
545
+ } catch {
546
+ globalManifest = null;
547
+ }
548
+ if (globalManifest) {
549
+ return {
550
+ written: [],
551
+ skipped: [],
552
+ warning: `Jig is already installed globally for '${opts.agent}' (${globalReferenceDir} under your home directory). Installing again at project scope would leave two contradicting '${opts.agent}' skills. Run 'jig update' to refresh the global install instead, or choose a different --agent for this project.`
553
+ };
554
+ }
355
555
  }
356
556
  const installRoot = opts.scope === "global" ? opts.homeDir : opts.projectRoot;
557
+ const referenceDir = referenceDirFor(adapter, opts.scope);
357
558
  let existingManifest;
358
559
  try {
359
- existingManifest = readManifest(installRoot);
560
+ existingManifest = readManifest(installRoot, referenceDir);
360
561
  } catch {
361
562
  existingManifest = null;
362
563
  }
363
564
  const planned = [];
364
- const rulesDir = join3(opts.packageRoot, "rules");
365
- for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
366
- const body = readFileSync2(join3(rulesDir, file), "utf8");
367
- planned.push({ key: relKey(".jig", file), content: vendorHeader(file, opts.version) + body, checkable: true });
368
- }
369
- const tokensDir = join3(opts.packageRoot, "tokens");
370
- for (const file of readdirSync(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
371
- const body = readFileSync2(join3(tokensDir, file), "utf8");
565
+ const rulesDir = join6(opts.packageRoot, "rules");
566
+ for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
567
+ const body = readFileSync3(join6(rulesDir, file), "utf8");
372
568
  planned.push({
373
- key: relKey(".jig", "tokens", file),
374
- content: vendorHeader(file, opts.version, "css") + body,
569
+ key: relKey(referenceDir, "rules", file),
570
+ content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
571
+ checkable: true
572
+ });
573
+ }
574
+ for (const ref of referenceFiles(opts.packageRoot)) {
575
+ planned.push({
576
+ key: relKey(referenceDir, ...ref.relPath.split("/")),
577
+ content: vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content,
375
578
  checkable: true
376
579
  });
377
580
  }
378
581
  planned.push({
379
- key: relKey(".jig", "rules.index.json"),
380
- content: readFileSync2(join3(opts.packageRoot, "rules.index.json"), "utf8"),
582
+ key: relKey(referenceDir, "rules.index.json"),
583
+ content: readFileSync3(join6(opts.packageRoot, "rules.index.json"), "utf8"),
381
584
  checkable: true
382
585
  });
383
586
  for (const src of ["LICENSE", "NOTICE"]) {
384
587
  planned.push({
385
- key: relKey(".jig", src),
386
- content: readFileSync2(join3(opts.packageRoot, src), "utf8"),
588
+ key: relKey(referenceDir, src),
589
+ content: readFileSync3(join6(opts.packageRoot, src), "utf8"),
387
590
  checkable: false
388
591
  });
389
592
  }
390
- const skillBody = buildSkillBody(opts.packageRoot, opts.scope);
593
+ const rulesPath = rulesPathFor(referenceDir, opts.scope);
594
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
595
+ const { body: commandBody, subcommands } = buildCommandBody(
596
+ opts.packageRoot,
597
+ rulesPath,
598
+ opts.version,
599
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
600
+ );
601
+ const command = { commandBody, subcommands };
391
602
  const skillFiles = skillFilesFor(adapter, {
392
603
  version: opts.version,
393
604
  scope: opts.scope,
394
605
  skillBody,
395
- commandPrefix: "/jig "
606
+ commandPrefix: "/jig ",
607
+ ...command
396
608
  });
397
609
  for (const file of skillFiles) {
398
610
  const key = file.relPath;
399
- const abs = join3(installRoot, ...key.split("/"));
611
+ const abs = join6(installRoot, ...key.split("/"));
400
612
  const isBlockFile = file.content.includes(BLOCK_START);
401
- const content = isBlockFile && existsSync3(abs) ? upsertBlock(readFileSync2(abs, "utf8"), file.content) : file.content;
613
+ const content = isBlockFile && existsSync4(abs) ? upsertBlock(readFileSync3(abs, "utf8"), file.content) : file.content;
402
614
  planned.push({ key, content, checkable: !isBlockFile });
403
615
  }
404
616
  const files = {};
@@ -421,118 +633,296 @@ function install(opts) {
421
633
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
422
634
  files
423
635
  };
424
- writeManifest(installRoot, manifest);
425
- written.push(relKey(".jig", "manifest.json"));
636
+ writeManifest(installRoot, manifest, referenceDir);
637
+ written.push(relKey(referenceDir, "manifest.json"));
426
638
  return { written, skipped };
427
639
  }
428
640
 
429
641
  // src/commands/update.ts
430
- import { basename, dirname as dirname5, join as join5 } from "path";
431
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
642
+ import { dirname as dirname6, join as join9 } from "path";
643
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, readdirSync as readdirSync4, writeFileSync as writeFileSync5 } from "fs";
432
644
 
433
645
  // src/install/target.ts
434
646
  import { resolve } from "path";
435
- function resolveInstalled(projectRoot, homeDir) {
436
- const projectManifest = readManifest(projectRoot);
437
- const existing = projectManifest ?? readManifest(homeDir);
438
- if (!existing) return null;
647
+ function resolveAllInstalled(projectRoot, homeDir) {
648
+ const found = [];
649
+ const seen = /* @__PURE__ */ new Set();
439
650
  const sameRoot = resolve(projectRoot) === resolve(homeDir);
440
- const scope = !projectManifest ? "global" : sameRoot ? existing.scope : "project";
441
- const installRoot = scope === "global" ? homeDir : projectRoot;
442
- return { installRoot, scope, manifest: existing };
651
+ for (const adapter of ADAPTERS) {
652
+ const referenceDir = referenceDirFor(adapter, "project");
653
+ const manifest = readManifest(projectRoot, referenceDir);
654
+ if (manifest && manifest.agent !== adapter.name) {
655
+ getAdapter(manifest.agent);
656
+ }
657
+ if (manifest && manifest.agent === adapter.name) {
658
+ const collides = sameRoot && referenceDirFor(adapter, "global") === referenceDir;
659
+ const scope = collides ? manifest.scope : "project";
660
+ const installRoot = scope === "global" ? homeDir : projectRoot;
661
+ const key = `${resolve(installRoot)}\0${referenceDir}`;
662
+ if (!seen.has(key)) {
663
+ seen.add(key);
664
+ found.push({ installRoot, scope, manifest, referenceDir });
665
+ }
666
+ }
667
+ }
668
+ for (const adapter of ADAPTERS) {
669
+ const referenceDir = referenceDirFor(adapter, "global");
670
+ const manifest = readManifest(homeDir, referenceDir);
671
+ if (manifest && manifest.agent !== adapter.name) {
672
+ getAdapter(manifest.agent);
673
+ }
674
+ if (manifest && manifest.agent === adapter.name) {
675
+ const key = `${resolve(homeDir)}\0${referenceDir}`;
676
+ if (!seen.has(key)) {
677
+ seen.add(key);
678
+ found.push({ installRoot: homeDir, scope: "global", manifest, referenceDir });
679
+ }
680
+ }
681
+ }
682
+ return found;
443
683
  }
444
684
 
445
685
  // src/init/state.ts
446
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
447
- import { dirname as dirname4, join as join4 } from "path";
448
- var INIT_MANIFEST_REL = join4(".jig", "init-manifest.json");
686
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
687
+ import { dirname as dirname5, join as join7 } from "path";
688
+ var INIT_MANIFEST_REL = join7(".jig", "state.json");
449
689
  function readInitManifest(projectRoot) {
450
- const path = join4(projectRoot, INIT_MANIFEST_REL);
451
- if (!existsSync4(path)) return null;
690
+ const path = join7(projectRoot, INIT_MANIFEST_REL);
691
+ if (!existsSync5(path)) return null;
452
692
  try {
453
- const raw = JSON.parse(readFileSync3(path, "utf8"));
693
+ const raw = JSON.parse(readFileSync4(path, "utf8"));
454
694
  if (typeof raw !== "object" || raw === null) return null;
455
- const files = raw.files;
695
+ const { files, version: version2, modes } = raw;
456
696
  if (typeof files !== "object" || files === null) return null;
457
- return { files };
697
+ return {
698
+ files,
699
+ version: typeof version2 === "string" ? version2 : void 0,
700
+ modes: Array.isArray(modes) && modes.every((m) => typeof m === "string") ? modes : void 0
701
+ };
458
702
  } catch {
459
703
  return null;
460
704
  }
461
705
  }
706
+ var MERGE_ATTEMPTS2 = 5;
462
707
  function writeInitManifest(projectRoot, m) {
463
- const path = join4(projectRoot, INIT_MANIFEST_REL);
464
- mkdirSync3(dirname4(path), { recursive: true });
465
- writeFileSync3(path, `${JSON.stringify(m, null, 2)}
466
- `, "utf8");
708
+ const path = join7(projectRoot, INIT_MANIFEST_REL);
709
+ mkdirSync4(dirname5(path), { recursive: true });
710
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
711
+ const onDisk = readInitManifest(projectRoot);
712
+ const merged = {
713
+ ...m,
714
+ modes: [.../* @__PURE__ */ new Set([...onDisk?.modes ?? [], ...m.modes ?? []])],
715
+ files: { ...onDisk?.files ?? {}, ...m.files }
716
+ };
717
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
718
+ `);
719
+ const after = readInitManifest(projectRoot);
720
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
721
+ }
467
722
  }
468
723
  function isInitFileModified(projectRoot, relPath, m) {
469
724
  const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
470
725
  return isModified(projectRoot, relPath, asManifest);
471
726
  }
472
727
 
728
+ // src/init/migrate.ts
729
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync } from "fs";
730
+ import { join as join8 } from "path";
731
+ var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
732
+ function isLegacyInstallArtifact(name) {
733
+ return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
734
+ }
735
+ function readLegacyManifest(dirAbs) {
736
+ try {
737
+ const raw = JSON.parse(readFileSync5(join8(dirAbs, "manifest.json"), "utf8"));
738
+ if (raw && typeof raw === "object" && typeof raw.files === "object") {
739
+ return raw;
740
+ }
741
+ } catch {
742
+ }
743
+ return null;
744
+ }
745
+ function classify(projectRoot, relPath, legacyManifest) {
746
+ return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
747
+ }
748
+ function detectLegacyRules(projectRoot) {
749
+ const jigDir = join8(projectRoot, ".jig");
750
+ if (!existsSync6(jigDir)) return { present: false, files: [] };
751
+ const legacyManifest = readLegacyManifest(jigDir);
752
+ let entries;
753
+ try {
754
+ entries = readdirSync3(jigDir);
755
+ } catch {
756
+ entries = [];
757
+ }
758
+ const files = entries.filter(isLegacyInstallArtifact).sort().map((name) => {
759
+ const relPath = `.jig/${name}`;
760
+ return { relPath, modified: classify(projectRoot, relPath, legacyManifest) };
761
+ });
762
+ return { present: files.length > 0, files };
763
+ }
764
+ function detectLegacyCursorRules(projectRoot) {
765
+ const bundleDirRel = ".cursor/rules/jig";
766
+ const bundleDir = join8(projectRoot, ...bundleDirRel.split("/"));
767
+ const mdcRel = ".cursor/rules/jig.mdc";
768
+ const mdcAbs = join8(projectRoot, ...mdcRel.split("/"));
769
+ const legacyManifest = readLegacyManifest(bundleDir);
770
+ const files = [];
771
+ if (existsSync6(mdcAbs)) {
772
+ files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
773
+ }
774
+ let entries;
775
+ try {
776
+ entries = readdirSync3(bundleDir);
777
+ } catch {
778
+ entries = [];
779
+ }
780
+ for (const name of entries.filter(isLegacyInstallArtifact).sort()) {
781
+ const relPath = `${bundleDirRel}/${name}`;
782
+ files.push({ relPath, modified: classify(projectRoot, relPath, legacyManifest) });
783
+ }
784
+ return { present: files.length > 0, files };
785
+ }
786
+ function describeLegacyFiles(report, headerLine, subheaderLine) {
787
+ if (!report.present) return [];
788
+ const lines = [];
789
+ lines.push(`
790
+ ${headerLine}`);
791
+ lines.push(subheaderLine);
792
+ for (const f of report.files) lines.push(` ${f.relPath}`);
793
+ const edited = report.files.filter((f) => f.modified === true).map((f) => f.relPath);
794
+ const unknown = report.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
795
+ if (edited.length) {
796
+ lines.push(" Edited since install \u2014 kept, never removed automatically:");
797
+ for (const relPath of edited) lines.push(` ${relPath}`);
798
+ }
799
+ if (unknown.length) {
800
+ lines.push(" No install record to verify against \u2014 kept; remove manually if you are sure they are untouched:");
801
+ for (const relPath of unknown) lines.push(` ${relPath}`);
802
+ }
803
+ return lines;
804
+ }
805
+ function describeLegacyReport(report) {
806
+ return describeLegacyFiles(
807
+ report,
808
+ `Found a pre-0.4.0 Jig install in .jig/ (${report.files.length} file(s)). These are no longer used \u2014`,
809
+ " since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
810
+ );
811
+ }
812
+ function describeLegacyCursorReport(report) {
813
+ return describeLegacyFiles(
814
+ report,
815
+ `Found a legacy Cursor install (${report.files.length} file(s)). Cursor's skill has moved \u2014`,
816
+ " from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
817
+ );
818
+ }
819
+ function removableLegacyFiles(report) {
820
+ return report.files.filter((f) => f.modified === false).map((f) => f.relPath);
821
+ }
822
+ function removeLegacyFiles(projectRoot, relPaths) {
823
+ for (const relPath of relPaths) {
824
+ try {
825
+ rmSync(join8(projectRoot, ...relPath.split("/")), { force: true });
826
+ } catch {
827
+ }
828
+ }
829
+ }
830
+
473
831
  // src/commands/update.ts
474
- var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
475
832
  function update(opts) {
476
- const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
477
- if (!resolved) {
833
+ const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
834
+ if (resolved.length === 0) {
835
+ const legacy = detectLegacyRules(opts.projectRoot);
836
+ const hint = legacy.present ? ` A pre-0.4.0 bundle is still in .jig/ (${legacy.files.length} file(s)); since 0.4.0 the skill and its rules live beside your agent's skill file instead, so that copy is no longer read. Re-run install, then 'jig init' will report the leftovers \u2014 it never removes a file you have edited.` : "";
478
837
  throw new Error(
479
- `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`
838
+ `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.${hint}`
480
839
  );
481
840
  }
482
- const { installRoot, scope: discoveredScope, manifest: existing } = resolved;
483
- const adapter = getAdapter(existing.agent);
484
- if (!adapter.supportsScope(discoveredScope)) {
485
- throw new Error(
486
- `Jig's manifest at ${installRoot} records agent '${existing.agent}', which does not support ${discoveredScope} scope. Re-run 'jig install --agent <name>' to fix it.`
487
- );
841
+ const updated = [];
842
+ const skipped = [];
843
+ const targets = [];
844
+ for (const target of resolved) {
845
+ const one = updateTarget(opts, target);
846
+ updated.push(...one.updated);
847
+ skipped.push(...one.skipped);
848
+ targets.push({
849
+ agent: target.manifest.agent,
850
+ scope: target.scope,
851
+ referenceDir: target.referenceDir,
852
+ fromVersion: target.manifest.version
853
+ });
488
854
  }
855
+ const initResult = updateInitFiles(opts);
856
+ updated.push(...initResult.updated);
857
+ skipped.push(...initResult.skipped);
858
+ return {
859
+ updated,
860
+ skipped,
861
+ fromVersion: resolved[0].manifest.version,
862
+ toVersion: opts.version,
863
+ targets
864
+ };
865
+ }
866
+ function updateTarget(opts, resolved) {
867
+ const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
868
+ const adapter = getAdapter(existing.agent);
489
869
  const updated = [];
490
870
  const skipped = [];
491
871
  const files = { ...existing.files };
492
872
  const write = (key, content) => {
493
- const abs = join5(installRoot, ...key.split("/"));
494
- mkdirSync4(dirname5(abs), { recursive: true });
495
- writeFileSync4(abs, content, "utf8");
496
- files[key] = checksum(content);
873
+ const abs = join9(installRoot, ...key.split("/"));
874
+ mkdirSync5(dirname6(abs), { recursive: true });
875
+ const existing2 = existsSync7(abs) ? readFileSync6(abs, "utf8") : void 0;
876
+ const toWrite = matchLineEndings(content, existing2);
877
+ writeFileSync5(abs, toWrite, "utf8");
878
+ files[key] = checksum(toWrite);
497
879
  updated.push(key);
498
880
  };
499
- const rulesDir = join5(opts.packageRoot, "rules");
500
- for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
501
- const key = relKey(".jig", file);
881
+ const rulesDir = join9(opts.packageRoot, "rules");
882
+ for (const file of readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
883
+ const key = relKey(referenceDir, "rules", file);
502
884
  if (isModified(installRoot, key, existing)) {
503
885
  skipped.push(key);
504
886
  continue;
505
887
  }
506
- write(key, vendorHeader(file, opts.version) + readFileSync4(join5(rulesDir, file), "utf8"));
888
+ write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync6(join9(rulesDir, file), "utf8"));
507
889
  }
508
- const tokensDir = join5(opts.packageRoot, "tokens");
509
- for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
510
- const key = relKey(".jig", "tokens", file);
890
+ for (const ref of referenceFiles(opts.packageRoot)) {
891
+ const key = relKey(referenceDir, ...ref.relPath.split("/"));
511
892
  if (isModified(installRoot, key, existing)) {
512
893
  skipped.push(key);
513
894
  continue;
514
895
  }
515
- write(key, vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8"));
896
+ write(key, vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content);
516
897
  }
517
- const indexKey = relKey(".jig", "rules.index.json");
898
+ const indexKey = relKey(referenceDir, "rules.index.json");
518
899
  if (isModified(installRoot, indexKey, existing)) {
519
900
  skipped.push(indexKey);
520
901
  } else {
521
- write(indexKey, readFileSync4(join5(opts.packageRoot, "rules.index.json"), "utf8"));
522
- }
523
- const skillBody = buildSkillBody(opts.packageRoot, discoveredScope);
902
+ write(indexKey, readFileSync6(join9(opts.packageRoot, "rules.index.json"), "utf8"));
903
+ }
904
+ const rulesPath = rulesPathFor(referenceDir, discoveredScope);
905
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
906
+ const { body: commandBody, subcommands } = buildCommandBody(
907
+ opts.packageRoot,
908
+ rulesPath,
909
+ opts.version,
910
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
911
+ );
912
+ const command = { commandBody, subcommands };
524
913
  const skillFiles = skillFilesFor(adapter, {
525
914
  version: opts.version,
526
915
  scope: discoveredScope,
527
916
  skillBody,
528
- commandPrefix: "/jig "
917
+ commandPrefix: "/jig ",
918
+ ...command
529
919
  });
530
920
  for (const file of skillFiles) {
531
921
  const key = file.relPath;
532
922
  const isBlockFile = file.content.includes(BLOCK_START);
533
923
  if (isBlockFile) {
534
- const abs = join5(installRoot, ...key.split("/"));
535
- const current = existsSync5(abs) ? readFileSync4(abs, "utf8") : "";
924
+ const abs = join9(installRoot, ...key.split("/"));
925
+ const current = existsSync7(abs) ? readFileSync6(abs, "utf8") : "";
536
926
  write(key, upsertBlock(current, file.content));
537
927
  continue;
538
928
  }
@@ -542,44 +932,90 @@ function update(opts) {
542
932
  }
543
933
  write(key, file.content);
544
934
  }
545
- for (const key of ALWAYS_REPLACE) {
546
- write(key, readFileSync4(join5(opts.packageRoot, basename(key)), "utf8"));
935
+ for (const src of ["LICENSE", "NOTICE"]) {
936
+ write(relKey(referenceDir, src), readFileSync6(join9(opts.packageRoot, src), "utf8"));
547
937
  }
938
+ const manifest = {
939
+ ...existing,
940
+ scope: discoveredScope,
941
+ version: opts.version,
942
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
943
+ files
944
+ };
945
+ writeManifest(installRoot, manifest, referenceDir);
946
+ return { updated, skipped };
947
+ }
948
+ function updateInitFiles(opts) {
949
+ const updated = [];
950
+ const skipped = [];
951
+ const tokensDir = join9(opts.packageRoot, "tokens");
548
952
  const initManifest = readInitManifest(opts.projectRoot);
549
953
  if (initManifest) {
550
954
  const initFiles = { ...initManifest.files };
551
955
  let initChanged = false;
552
- for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
956
+ for (const file of readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
553
957
  const key = relKey(".jig", "tokens", file);
554
958
  if (!(key in initManifest.files)) continue;
555
959
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
556
960
  skipped.push(key);
557
961
  continue;
558
962
  }
559
- const content = vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8");
560
- const abs = join5(opts.projectRoot, ...key.split("/"));
561
- mkdirSync4(dirname5(abs), { recursive: true });
562
- writeFileSync4(abs, content, "utf8");
963
+ const content = vendorHeader(file, opts.version, "css", null) + readFileSync6(join9(tokensDir, file), "utf8");
964
+ const abs = join9(opts.projectRoot, ...key.split("/"));
965
+ mkdirSync5(dirname6(abs), { recursive: true });
966
+ writeFileSync5(abs, content, "utf8");
563
967
  initFiles[key] = checksum(content);
564
968
  initChanged = true;
565
969
  updated.push(key);
566
970
  }
567
- if (initChanged) writeInitManifest(opts.projectRoot, { files: initFiles });
971
+ if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
568
972
  }
569
- const manifest = {
570
- ...existing,
571
- scope: discoveredScope,
572
- version: opts.version,
573
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
574
- files
575
- };
576
- writeManifest(installRoot, manifest);
577
- return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
973
+ return { updated, skipped };
578
974
  }
579
975
 
580
- // src/commands/check.ts
581
- import { readFileSync as readFileSync7 } from "fs";
582
- import { join as join9 } from "path";
976
+ // src/commands/explain.ts
977
+ import { join as join12 } from "path";
978
+
979
+ // src/rules/load.ts
980
+ import { readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
981
+ import { join as join10 } from "path";
982
+
983
+ // src/rules/parse.ts
984
+ var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
985
+ function parseRules(markdown, sourceFile) {
986
+ const lines = markdown.split("\n");
987
+ const rules = [];
988
+ let current = null;
989
+ const push = () => {
990
+ if (current) rules.push(current);
991
+ };
992
+ for (const line of lines) {
993
+ const heading = HEADING.exec(line);
994
+ if (heading) {
995
+ push();
996
+ const [, section, num, title] = heading;
997
+ const id = `${section}-${num}`;
998
+ current = {
999
+ id,
1000
+ section,
1001
+ number: Number(num),
1002
+ title,
1003
+ wrong: "",
1004
+ correction: "",
1005
+ source: `${sourceFile}#${id.toLowerCase()}`
1006
+ };
1007
+ continue;
1008
+ }
1009
+ if (!current) continue;
1010
+ if (line.startsWith("\u274C") && !current.wrong) {
1011
+ current.wrong = line.slice(1).trim();
1012
+ } else if (line.startsWith("\u2705") && !current.correction) {
1013
+ current.correction = line.slice(1).trim();
1014
+ }
1015
+ }
1016
+ push();
1017
+ return rules;
1018
+ }
583
1019
 
584
1020
  // src/rules/schema.ts
585
1021
  var BUCKETS = ["mechanical", "judgment", "hybrid"];
@@ -612,10 +1048,130 @@ function validateIndex(entries) {
612
1048
  });
613
1049
  }
614
1050
 
1051
+ // src/rules/load.ts
1052
+ function loadRules(rulesDir, indexPath) {
1053
+ const files = readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort();
1054
+ const parsed = [];
1055
+ for (const file of files) {
1056
+ parsed.push(...parseRules(readFileSync7(join10(rulesDir, file), "utf8"), file));
1057
+ }
1058
+ const entries = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
1059
+ const byId = new Map(entries.map((e) => [e.id, e]));
1060
+ const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1061
+ if (missing.length) {
1062
+ throw new Error(
1063
+ `Rules present in markdown but missing from rules.index.json: ${missing.join(", ")}`
1064
+ );
1065
+ }
1066
+ const parsedIds = new Set(parsed.map((r) => r.id));
1067
+ const orphans = entries.filter((e) => !parsedIds.has(e.id)).map((e) => e.id);
1068
+ if (orphans.length) {
1069
+ throw new Error(
1070
+ `Entries in rules.index.json with no matching rule: ${orphans.join(", ")}`
1071
+ );
1072
+ }
1073
+ return parsed.map((r) => {
1074
+ const { id: _id, ...meta } = byId.get(r.id);
1075
+ return { ...r, ...meta };
1076
+ });
1077
+ }
1078
+
1079
+ // src/rules/specs.ts
1080
+ import { readFileSync as readFileSync8, readdirSync as readdirSync6 } from "fs";
1081
+ import { join as join11 } from "path";
1082
+ var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1083
+ function parseSpecs(markdown, sourceFile) {
1084
+ const lines = markdown.split("\n");
1085
+ const specs = [];
1086
+ let current = null;
1087
+ const body = [];
1088
+ const push = () => {
1089
+ if (!current) return;
1090
+ specs.push({ ...current, body: body.join("\n").replace(/\n{3,}/g, "\n\n").trim() });
1091
+ body.length = 0;
1092
+ };
1093
+ for (const line of lines) {
1094
+ const heading = HEADING2.exec(line);
1095
+ if (heading) {
1096
+ push();
1097
+ const [, id, title] = heading;
1098
+ current = { id, title, body: "", source: `${sourceFile}#${id.toLowerCase()}` };
1099
+ continue;
1100
+ }
1101
+ if (current && /^##\s/.test(line)) {
1102
+ push();
1103
+ current = null;
1104
+ continue;
1105
+ }
1106
+ if (current) body.push(line);
1107
+ }
1108
+ push();
1109
+ return specs;
1110
+ }
1111
+ function loadSpecs(rulesDir) {
1112
+ const specs = [];
1113
+ for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1114
+ specs.push(...parseSpecs(readFileSync8(join11(rulesDir, file), "utf8"), file));
1115
+ }
1116
+ return specs;
1117
+ }
1118
+
1119
+ // src/commands/explain.ts
1120
+ var ID_SHAPE = /^([A-Z])-(\d+)$/;
1121
+ function explain(opts) {
1122
+ const raw = opts.ruleId.trim().toUpperCase();
1123
+ const shape = ID_SHAPE.exec(raw);
1124
+ if (!shape) {
1125
+ throw new Error(
1126
+ `'${opts.ruleId.trim()}' is not a rule id. Ids look like 'C-19' or 'P-02' \u2014 a section letter, a hyphen, and a number.`
1127
+ );
1128
+ }
1129
+ const root = opts.packageRoot ?? assetRoot();
1130
+ const rulesDir = join12(root, "rules");
1131
+ const rules = loadRules(rulesDir, join12(root, "rules.index.json"));
1132
+ const rule = rules.find((r) => r.id === raw);
1133
+ if (rule) {
1134
+ const lines = [
1135
+ `${rule.id} ${rule.title}`,
1136
+ "",
1137
+ `\u274C ${rule.wrong}`,
1138
+ `\u2705 ${rule.correction}`,
1139
+ "",
1140
+ ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1141
+ ` since ${rule.since} \xB7 ${rule.source}`
1142
+ ];
1143
+ return lines.join("\n");
1144
+ }
1145
+ const spec = loadSpecs(rulesDir).find((s) => s.id === raw);
1146
+ if (spec) {
1147
+ return [
1148
+ `${spec.id} ${spec.title}`,
1149
+ "",
1150
+ spec.body,
1151
+ "",
1152
+ ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1153
+ ` so it carries no \u274C/\u2705 pair and no detector.`,
1154
+ ` ${spec.source}`
1155
+ ].join("\n");
1156
+ }
1157
+ const section = shape[1];
1158
+ const siblings = [
1159
+ ...rules.filter((r) => r.id.startsWith(`${section}-`)).map((r) => r.id),
1160
+ ...loadSpecs(rulesDir).filter((s) => s.id.startsWith(`${section}-`)).map((s) => s.id)
1161
+ ].sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
1162
+ throw new Error(
1163
+ siblings.length > 0 ? `No rule '${raw}'. Section ${section} has: ${siblings.join(", ")}.` : `No rule '${raw}', and no section '${section}' exists.`
1164
+ );
1165
+ }
1166
+
1167
+ // src/commands/check.ts
1168
+ import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
1169
+ import { join as join16 } from "path";
1170
+
615
1171
  // src/check/files.ts
616
1172
  import { execFileSync } from "child_process";
617
- import { readdirSync as readdirSync3 } from "fs";
618
- import { join as join6, relative, sep } from "path";
1173
+ import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
1174
+ import { join as join13, relative, sep } from "path";
619
1175
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
620
1176
  "node_modules",
621
1177
  "dist",
@@ -671,19 +1227,38 @@ function gitChangedFiles(root) {
671
1227
  function isExcluded(relPath) {
672
1228
  return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
673
1229
  }
674
- function walk(root, dir, out) {
1230
+ function walk(root, dir, out, seen) {
675
1231
  let entries;
676
1232
  try {
677
- entries = readdirSync3(dir, { withFileTypes: true });
1233
+ entries = readdirSync7(dir, { withFileTypes: true });
678
1234
  } catch {
679
1235
  return;
680
1236
  }
681
1237
  for (const entry of entries) {
682
1238
  if (EXCLUDE_DIRS.has(entry.name)) continue;
683
- const abs = join6(dir, entry.name);
684
- if (entry.isDirectory()) {
685
- walk(root, abs, out);
686
- } else if (entry.isFile()) {
1239
+ const abs = join13(dir, entry.name);
1240
+ let isDir = entry.isDirectory();
1241
+ let isFile = entry.isFile();
1242
+ if (entry.isSymbolicLink()) {
1243
+ try {
1244
+ const target = statSync(abs);
1245
+ isDir = target.isDirectory();
1246
+ isFile = target.isFile();
1247
+ } catch {
1248
+ continue;
1249
+ }
1250
+ }
1251
+ if (isDir) {
1252
+ let real;
1253
+ try {
1254
+ real = realpathSync(abs);
1255
+ } catch {
1256
+ continue;
1257
+ }
1258
+ if (seen.has(real)) continue;
1259
+ seen.add(real);
1260
+ walk(root, abs, out, seen);
1261
+ } else if (isFile) {
687
1262
  out.push(relative(root, abs).split(sep).join("/"));
688
1263
  }
689
1264
  }
@@ -712,9 +1287,16 @@ function filterGitIgnored(root, files) {
712
1287
  return files;
713
1288
  }
714
1289
  }
1290
+ function safeRealpath(p) {
1291
+ try {
1292
+ return realpathSync(p);
1293
+ } catch {
1294
+ return p;
1295
+ }
1296
+ }
715
1297
  function wholeRepoFiles(root) {
716
1298
  const out = [];
717
- walk(root, root, out);
1299
+ walk(root, root, out, /* @__PURE__ */ new Set([safeRealpath(root)]));
718
1300
  return isGitRepo(root) ? filterGitIgnored(root, out) : out;
719
1301
  }
720
1302
  function selectFiles(root, all) {
@@ -738,7 +1320,7 @@ function formatReport(findings, meta) {
738
1320
  const loc = `${f.file}:${f.line}`;
739
1321
  const firstOccurrence = !seenRule.has(f.ruleId);
740
1322
  seenRule.add(f.ruleId);
741
- const hint = firstOccurrence ? ` (see .jig/00-anti-patterns.md#${f.ruleId.toLowerCase()})` : "";
1323
+ const hint = firstOccurrence ? ` (see rule ${f.ruleId} in your installed jig skill's rules/)` : "";
742
1324
  return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
743
1325
  });
744
1326
  if (rows.length > 0) {
@@ -768,9 +1350,7 @@ function formatReport(findings, meta) {
768
1350
  lines.push("");
769
1351
  if (meta.noTokenLayer) {
770
1352
  lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
771
- lines.push(" Import a brand and a mode file to bring your CSS under the token layer:");
772
- lines.push(' @import ".jig/tokens/brand.default.css";');
773
- lines.push(' @import ".jig/tokens/mode.product.css";');
1353
+ lines.push(" Run 'jig init' to generate a brand file and wire it (with a mode file) into your CSS.");
774
1354
  lines.push("");
775
1355
  }
776
1356
  }
@@ -781,16 +1361,28 @@ function formatReport(findings, meta) {
781
1361
  if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
782
1362
  if (notes > 0) summaryParts.push(plural(notes, "note"));
783
1363
  const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
784
- lines.push(` ${summaryParts.join(", ")} \xB7 ${meta.totalRules} rules, ${rulesFired} fired`);
1364
+ const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1365
+ lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
1366
+ if (meta.unscanned && meta.unscanned.count > 0) {
1367
+ const exts = meta.unscanned.extensions.join(", ");
1368
+ lines.push("");
1369
+ lines.push(
1370
+ ` ${meta.unscanned.count} file(s) were not scanned (${exts}) \u2014 no style extraction exists for them, so any CSS they carry is invisible to this check and a clean result above does not cover it.`
1371
+ );
1372
+ }
785
1373
  const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
786
1374
  const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
787
- lines.push(` JIG_CHECK: version=${meta.version} mechanical=${mechStatus} judgment=not-run`);
1375
+ lines.push(
1376
+ ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
1377
+ );
788
1378
  return lines.join("\n");
789
1379
  }
790
1380
 
791
1381
  // src/check/token-layer.ts
792
1382
  var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
793
1383
  var VAR_RE = /var\(\s*--([\w-]+)/g;
1384
+ var THEME_DECL_RE = /@theme\b[^{]*\{([\s\S]*?)\}/gi;
1385
+ var CUSTOM_PROP_RE = /--([\w-]+)\s*:/g;
794
1386
  function participatesInTokenLayer(source, tokens) {
795
1387
  if (IMPORT_RE.test(source)) return true;
796
1388
  VAR_RE.lastIndex = 0;
@@ -798,12 +1390,91 @@ function participatesInTokenLayer(source, tokens) {
798
1390
  while (m = VAR_RE.exec(source)) {
799
1391
  if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
800
1392
  }
1393
+ THEME_DECL_RE.lastIndex = 0;
1394
+ let theme;
1395
+ while (theme = THEME_DECL_RE.exec(source)) {
1396
+ if (IMPORT_RE.test(theme[1])) return true;
1397
+ CUSTOM_PROP_RE.lastIndex = 0;
1398
+ let prop;
1399
+ while (prop = CUSTOM_PROP_RE.exec(theme[1])) {
1400
+ if (Object.prototype.hasOwnProperty.call(tokens, prop[1])) return true;
1401
+ }
1402
+ }
801
1403
  return false;
802
1404
  }
803
1405
 
1406
+ // src/check/ext.ts
1407
+ var CSS_EXTENSIONS = [".css", ".scss", ".less"];
1408
+ function hasExtension(file, exts) {
1409
+ const lower = file.toLowerCase();
1410
+ return exts.some((e) => lower.endsWith(e));
1411
+ }
1412
+ var STYLE_HOST_EXTENSIONS = [
1413
+ ".html",
1414
+ ".htm",
1415
+ ".vue",
1416
+ ".svelte",
1417
+ ".astro",
1418
+ ".jsx",
1419
+ ".tsx",
1420
+ ".js",
1421
+ ".ts",
1422
+ ".mjs",
1423
+ ".cjs",
1424
+ ".mts",
1425
+ ".cts",
1426
+ ".php",
1427
+ ".erb",
1428
+ ".twig",
1429
+ ".hbs",
1430
+ ".mdx",
1431
+ // Server-rendered markup. Every one of these is HTML with a template syntax
1432
+ // threaded through it, so its `<style>` blocks and `style` attributes are the
1433
+ // same constructs already handled — the template tags sit outside the style
1434
+ // regions and are blanked with the rest of the markup.
1435
+ ".asp",
1436
+ ".aspx",
1437
+ ".ascx",
1438
+ ".master",
1439
+ // Classic ASP, ASP.NET
1440
+ ".cshtml",
1441
+ ".vbhtml",
1442
+ ".razor",
1443
+ // Razor
1444
+ ".jsp",
1445
+ ".jspx",
1446
+ // Java
1447
+ ".eex",
1448
+ ".heex",
1449
+ ".leex",
1450
+ // Phoenix
1451
+ ".ejs",
1452
+ ".njk",
1453
+ ".liquid",
1454
+ ".mustache",
1455
+ // Node/Jinja-family engines
1456
+ ".vm",
1457
+ ".ftl",
1458
+ // Velocity, FreeMarker
1459
+ ".jinja",
1460
+ ".jinja2",
1461
+ ".j2",
1462
+ // Jinja
1463
+ // Indentation-delimited templates. These carry no HTML-shaped constructs at
1464
+ // all — their style regions are found by their own block markers and
1465
+ // attribute syntax instead. See `INDENTED_SYNTAXES` in styles.ts.
1466
+ ".pug",
1467
+ ".jade",
1468
+ ".haml",
1469
+ ".slim"
1470
+ ];
1471
+ function isStyleBearing(file) {
1472
+ return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
1473
+ }
1474
+
804
1475
  // src/check/run.ts
805
- import { readFileSync as readFileSync5 } from "fs";
806
- import { join as join7 } from "path";
1476
+ import { readFileSync as readFileSync9 } from "fs";
1477
+ import { join as join14 } from "path";
807
1478
 
808
1479
  // src/check/css.ts
809
1480
  function maskComments(source) {
@@ -870,7 +1541,7 @@ function splitRuleBlocks(source) {
870
1541
  }
871
1542
  function blankNested(body) {
872
1543
  const out = body.split("");
873
- const blank = (from, to) => {
1544
+ const blank2 = (from, to) => {
874
1545
  for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
875
1546
  };
876
1547
  let depth = 0;
@@ -878,7 +1549,7 @@ function blankNested(body) {
878
1549
  for (let i = 0; i < body.length; i++) {
879
1550
  const ch = body[i];
880
1551
  if (ch === "{") {
881
- if (depth === 0) blank(segmentStart, i);
1552
+ if (depth === 0) blank2(segmentStart, i);
882
1553
  depth++;
883
1554
  out[i] = " ";
884
1555
  } else if (ch === "}") {
@@ -929,11 +1600,221 @@ function sourceLine(source, line) {
929
1600
  return (lines[line - 1] ?? "").trim();
930
1601
  }
931
1602
 
932
- // src/check/ext.ts
933
- var CSS_EXTENSIONS = [".css", ".scss", ".less"];
934
- function hasExtension(file, exts) {
1603
+ // src/check/styles.ts
1604
+ function isStyleHost(file) {
1605
+ return hasExtension(file, STYLE_HOST_EXTENSIONS);
1606
+ }
1607
+ function isMarkupHost(file) {
1608
+ return hasExtension(file, MARKUP_HOSTS);
1609
+ }
1610
+ var MARKUP_HOSTS = [
1611
+ ".html",
1612
+ ".htm",
1613
+ ".vue",
1614
+ ".svelte",
1615
+ ".astro",
1616
+ ".php",
1617
+ ".erb",
1618
+ ".twig",
1619
+ ".hbs",
1620
+ ".mdx",
1621
+ ".jsx",
1622
+ ".tsx",
1623
+ ".asp",
1624
+ ".aspx",
1625
+ ".ascx",
1626
+ ".master",
1627
+ ".cshtml",
1628
+ ".vbhtml",
1629
+ ".razor",
1630
+ ".jsp",
1631
+ ".jspx",
1632
+ ".eex",
1633
+ ".heex",
1634
+ ".leex",
1635
+ ".ejs",
1636
+ ".njk",
1637
+ ".liquid",
1638
+ ".mustache",
1639
+ ".vm",
1640
+ ".ftl",
1641
+ ".jinja",
1642
+ ".jinja2",
1643
+ ".j2"
1644
+ ];
1645
+ var INDENTED_SYNTAXES = {
1646
+ ".pug": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1647
+ ".jade": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1648
+ ".haml": {
1649
+ blockMarker: /^(\s*)(?::css|%style)\s*$/,
1650
+ attr: /(?::style\s*=>|\bstyle\s*:)\s*(["'])([\s\S]*?)\1/g
1651
+ },
1652
+ ".slim": {
1653
+ blockMarker: /^(\s*)(?:css:|style:)\s*$/,
1654
+ attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g
1655
+ }
1656
+ };
1657
+ function indentedSyntaxFor(file) {
935
1658
  const lower = file.toLowerCase();
936
- return exts.some((e) => lower.endsWith(e));
1659
+ const ext = Object.keys(INDENTED_SYNTAXES).find((e) => lower.endsWith(e));
1660
+ return ext ? INDENTED_SYNTAXES[ext] : void 0;
1661
+ }
1662
+ function indentedSpans(source, syntax) {
1663
+ const spans = [];
1664
+ const lines = source.split("\n");
1665
+ const offsets = [];
1666
+ let at = 0;
1667
+ for (const line of lines) {
1668
+ offsets.push(at);
1669
+ at += line.length + 1;
1670
+ }
1671
+ for (let i = 0; i < lines.length; i++) {
1672
+ const marker = syntax.blockMarker.exec(lines[i]);
1673
+ if (!marker) continue;
1674
+ const markerIndent = marker[1].length;
1675
+ let end = i;
1676
+ for (let j = i + 1; j < lines.length; j++) {
1677
+ if (lines[j].trim() === "") {
1678
+ end = j;
1679
+ continue;
1680
+ }
1681
+ const indent = lines[j].length - lines[j].trimStart().length;
1682
+ if (indent <= markerIndent) break;
1683
+ end = j;
1684
+ }
1685
+ if (end === i) continue;
1686
+ const start = offsets[i + 1];
1687
+ const last = offsets[end] + lines[end].length;
1688
+ spans.push({ start, end: last });
1689
+ i = end;
1690
+ }
1691
+ return spans;
1692
+ }
1693
+ var STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
1694
+ var STYLE_ATTR = /\bstyle\s*=\s*(["'])([\s\S]*?)\1/gi;
1695
+ var CSS_TAG = /\b(?:styled(?:\.\w+|\([^)]*\))(?:\s*\.\w+\([^)]*\))*|css|createGlobalStyle|keyframes|injectGlobal)\s*`/g;
1696
+ function readTemplate(source, openBacktick) {
1697
+ let i = openBacktick + 1;
1698
+ const start = i;
1699
+ while (i < source.length) {
1700
+ const ch = source[i];
1701
+ if (ch === "\\") {
1702
+ i += 2;
1703
+ continue;
1704
+ }
1705
+ if (ch === "`") return { start, end: i };
1706
+ if (ch === "$" && source[i + 1] === "{") {
1707
+ let depth = 1;
1708
+ i += 2;
1709
+ while (i < source.length && depth > 0) {
1710
+ if (source[i] === "\\") {
1711
+ i += 2;
1712
+ continue;
1713
+ }
1714
+ if (source[i] === "{") depth++;
1715
+ else if (source[i] === "}") depth--;
1716
+ else if (source[i] === "`") {
1717
+ const inner = readTemplate(source, i);
1718
+ if (!inner) return null;
1719
+ i = inner.end;
1720
+ }
1721
+ i++;
1722
+ }
1723
+ continue;
1724
+ }
1725
+ i++;
1726
+ }
1727
+ return null;
1728
+ }
1729
+ var STYLE_OBJECT = /\bstyle\s*=\s*\{\{/g;
1730
+ function readObject(source, open) {
1731
+ let depth = 0;
1732
+ let i = open + 1;
1733
+ const start = open + 2;
1734
+ while (i < source.length) {
1735
+ const ch = source[i];
1736
+ if (ch === '"' || ch === "'" || ch === "`") {
1737
+ const quote = ch;
1738
+ i++;
1739
+ while (i < source.length && source[i] !== quote) {
1740
+ if (source[i] === "\\") i++;
1741
+ i++;
1742
+ }
1743
+ i++;
1744
+ continue;
1745
+ }
1746
+ if (ch === "{") depth++;
1747
+ else if (ch === "}") {
1748
+ depth--;
1749
+ if (depth === 0) return { start, end: i };
1750
+ }
1751
+ i++;
1752
+ }
1753
+ return null;
1754
+ }
1755
+ function styleObjectToCss(body) {
1756
+ let out = "";
1757
+ for (const ch of body) {
1758
+ if (ch === '"' || ch === "'" || ch === "`") out += " ";
1759
+ else if (ch === ",") out += ";";
1760
+ else out += ch;
1761
+ }
1762
+ return out;
1763
+ }
1764
+ function maskNonStyleRegions(source, file) {
1765
+ if (hasExtension(file, CSS_EXTENSIONS)) return source;
1766
+ if (!isStyleHost(file)) return blank(source);
1767
+ const spans = [];
1768
+ const indented = indentedSyntaxFor(file);
1769
+ if (indented) {
1770
+ spans.push(...indentedSpans(source, indented));
1771
+ for (const m of source.matchAll(indented.attr)) {
1772
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1773
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1774
+ }
1775
+ return applySpans(source, spans);
1776
+ }
1777
+ if (hasExtension(file, MARKUP_HOSTS)) {
1778
+ for (const m of source.matchAll(STYLE_BLOCK)) {
1779
+ const bodyStart = m.index + m[0].indexOf(">") + 1;
1780
+ spans.push({ start: bodyStart, end: bodyStart + m[1].length });
1781
+ }
1782
+ for (const m of source.matchAll(STYLE_ATTR)) {
1783
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1784
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1785
+ }
1786
+ }
1787
+ for (const m of source.matchAll(CSS_TAG)) {
1788
+ const backtick = m.index + m[0].length - 1;
1789
+ const span = readTemplate(source, backtick);
1790
+ if (span) spans.push({ ...span, braced: true });
1791
+ }
1792
+ for (const m of source.matchAll(STYLE_OBJECT)) {
1793
+ const span = readObject(source, m.index + m[0].length - 2);
1794
+ if (span) spans.push({ ...span, transform: styleObjectToCss, braced: true });
1795
+ }
1796
+ return applySpans(source, spans);
1797
+ }
1798
+ function applySpans(source, spans) {
1799
+ if (spans.length === 0) return blank(source);
1800
+ const out = blank(source).split("");
1801
+ for (const span of spans) {
1802
+ const text = source.slice(span.start, span.end);
1803
+ const rendered = span.transform ? span.transform(text) : text;
1804
+ for (let i = 0; i < rendered.length; i++) {
1805
+ out[span.start + i] = rendered[i];
1806
+ }
1807
+ if (span.braced && span.start > 0 && span.end < out.length) {
1808
+ out[span.start - 1] = "{";
1809
+ out[span.end] = "}";
1810
+ }
1811
+ }
1812
+ return out.join("");
1813
+ }
1814
+ function blank(source) {
1815
+ let out = "";
1816
+ for (const ch of source) out += ch === "\n" ? "\n" : " ";
1817
+ return out;
937
1818
  }
938
1819
 
939
1820
  // src/check/finding.ts
@@ -955,7 +1836,7 @@ var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
955
1836
  var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
956
1837
  var gradientText = {
957
1838
  name: "gradient-text",
958
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
1839
+ appliesTo: (file) => isStyleBearing(file),
959
1840
  run(source, file, ctx) {
960
1841
  const findings = [];
961
1842
  for (const block of leafBlocks(source)) {
@@ -1184,7 +2065,7 @@ function hasTranslucentBackground(body, tokens) {
1184
2065
  }
1185
2066
  var backdropBlur = {
1186
2067
  name: "backdrop-blur",
1187
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2068
+ appliesTo: (file) => isStyleBearing(file),
1188
2069
  run(source, file, ctx) {
1189
2070
  const findings = [];
1190
2071
  for (const block of leafBlocks(source)) {
@@ -1215,7 +2096,7 @@ function isPure(rgb, target) {
1215
2096
  }
1216
2097
  var pureBlackWhite = {
1217
2098
  name: "pure-black-white",
1218
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2099
+ appliesTo: (file) => isStyleBearing(file),
1219
2100
  run(source, file, ctx) {
1220
2101
  const findings = [];
1221
2102
  for (const block of leafBlocks(source)) {
@@ -1250,6 +2131,104 @@ var pureBlackWhite = {
1250
2131
  }
1251
2132
  };
1252
2133
 
2134
+ // src/check/tailwind.ts
2135
+ var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2136
+ function readBraced(source, open) {
2137
+ let depth = 0;
2138
+ for (let i = open; i < source.length; i++) {
2139
+ const ch = source[i];
2140
+ if (ch === '"' || ch === "'" || ch === "`") {
2141
+ const quote = ch;
2142
+ i++;
2143
+ while (i < source.length && source[i] !== quote) {
2144
+ if (source[i] === "\\") i++;
2145
+ i++;
2146
+ }
2147
+ continue;
2148
+ }
2149
+ if (ch === "{") depth++;
2150
+ else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2151
+ }
2152
+ return null;
2153
+ }
2154
+ function classAttributeValues(source) {
2155
+ const out = [];
2156
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2157
+ for (const m of source.matchAll(CLASS_ATTR)) {
2158
+ const at = m.index + m[0].length;
2159
+ const opener = source[at];
2160
+ if (opener === '"' || opener === "'") {
2161
+ const end = source.indexOf(opener, at + 1);
2162
+ if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2163
+ continue;
2164
+ }
2165
+ if (opener !== "{") continue;
2166
+ const raw = readBraced(source, at);
2167
+ if (raw === null) continue;
2168
+ const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2169
+ (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2170
+ );
2171
+ if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2172
+ }
2173
+ return out;
2174
+ }
2175
+ var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2176
+ var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2177
+ var LENGTH = /^-?\d*\.?\d+(?:px|rem|em|pt|vh|vw|vmin|vmax|ch|ex)$/i;
2178
+ function arbitraryValues(classes) {
2179
+ const out = [];
2180
+ for (const m of classes.matchAll(ARBITRARY)) {
2181
+ const [, utility, rawValue] = m;
2182
+ const value = rawValue.replace(/_/g, " ");
2183
+ if (/var\(|--/.test(value)) continue;
2184
+ if (COLOUR.test(value)) out.push({ utility, value, kind: "colour" });
2185
+ else if (LENGTH.test(value)) out.push({ utility, value, kind: "length" });
2186
+ }
2187
+ return out;
2188
+ }
2189
+ var PALETTE = {
2190
+ white: "#ffffff",
2191
+ black: "#000000",
2192
+ "gray-50": "#f9fafb",
2193
+ "gray-100": "#f3f4f6",
2194
+ "gray-200": "#e5e7eb",
2195
+ "gray-300": "#d1d5db",
2196
+ "gray-400": "#9ca3af",
2197
+ "gray-500": "#6b7280",
2198
+ "gray-600": "#4b5563",
2199
+ "gray-700": "#374151",
2200
+ "gray-800": "#1f2937",
2201
+ "gray-900": "#111827",
2202
+ "gray-950": "#030712",
2203
+ "slate-50": "#f8fafc",
2204
+ "slate-400": "#94a3b8",
2205
+ "slate-500": "#64748b",
2206
+ "slate-800": "#1e293b",
2207
+ "slate-900": "#0f172a",
2208
+ "slate-950": "#020617",
2209
+ "zinc-400": "#a1a1aa",
2210
+ "zinc-500": "#71717a",
2211
+ "zinc-900": "#18181b",
2212
+ "neutral-400": "#a3a3a3",
2213
+ "neutral-500": "#737373",
2214
+ "neutral-900": "#171717",
2215
+ "stone-400": "#a8a29e",
2216
+ "stone-500": "#78716c",
2217
+ "stone-900": "#1c1917"
2218
+ };
2219
+ function palettePairs(classes) {
2220
+ let background;
2221
+ let foreground;
2222
+ for (const token of classes.split(/\s+/)) {
2223
+ const bare = token.replace(/^(?:[\w-]+:)*/, "");
2224
+ const bg = /^bg-(.+)$/.exec(bare);
2225
+ const fg = /^text-(.+)$/.exec(bare);
2226
+ if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2227
+ if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2228
+ }
2229
+ return background && foreground ? [{ background, foreground }] : [];
2230
+ }
2231
+
1253
2232
  // src/check/detectors/contrast-floor.ts
1254
2233
  var FLOOR = 4.5;
1255
2234
  var LARGE_FLOOR = 3;
@@ -1274,7 +2253,7 @@ function resolveFloor(body) {
1274
2253
  var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
1275
2254
  var contrastFloor = {
1276
2255
  name: "contrast-floor",
1277
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2256
+ appliesTo: (file) => isStyleBearing(file),
1278
2257
  run(source, file, ctx) {
1279
2258
  const findings = [];
1280
2259
  for (const block of leafBlocks(source)) {
@@ -1311,6 +2290,25 @@ var contrastFloor = {
1311
2290
  )
1312
2291
  );
1313
2292
  }
2293
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2294
+ for (const pair of palettePairs(attr.classes)) {
2295
+ const fg = resolveOpaqueColor(pair.foreground, ctx.tokens);
2296
+ const bg = resolveOpaqueColor(pair.background, ctx.tokens);
2297
+ if (!fg || !bg) continue;
2298
+ const ratio = contrastRatio(fg, bg);
2299
+ if (ratio >= 4.5) continue;
2300
+ findings.push(
2301
+ mkFinding(
2302
+ ctx,
2303
+ "contrast-floor",
2304
+ file,
2305
+ attr.line,
2306
+ `Foreground/background contrast is ${ratio.toFixed(2)}:1, below the 4.5:1 floor (${pair.foreground} on ${pair.background})`,
2307
+ sourceLine(ctx.raw, attr.line)
2308
+ )
2309
+ );
2310
+ }
2311
+ }
1314
2312
  return findings;
1315
2313
  }
1316
2314
  };
@@ -1320,7 +2318,7 @@ var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?
1320
2318
  var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
1321
2319
  var focusRemoved = {
1322
2320
  name: "focus-removed",
1323
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2321
+ appliesTo: (file) => isStyleBearing(file),
1324
2322
  run(source, file, ctx) {
1325
2323
  if (FOCUS_VISIBLE_RE.test(source)) return [];
1326
2324
  const findings = [];
@@ -1381,9 +2379,10 @@ var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
1381
2379
  var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
1382
2380
  var hardcodedValue = {
1383
2381
  name: "hardcoded-value",
1384
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2382
+ appliesTo: (file) => isStyleBearing(file),
1385
2383
  run(source, file, ctx) {
1386
- if (!participatesInTokenLayer(source, ctx.tokens)) return [];
2384
+ const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
2385
+ if (!participates) return [];
1387
2386
  const findings = [];
1388
2387
  for (const block of leafBlocks(source)) {
1389
2388
  if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
@@ -1433,6 +2432,20 @@ var hardcodedValue = {
1433
2432
  }
1434
2433
  }
1435
2434
  }
2435
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2436
+ for (const found of arbitraryValues(attr.classes)) {
2437
+ findings.push(
2438
+ mkFinding(
2439
+ ctx,
2440
+ "hardcoded-value",
2441
+ file,
2442
+ attr.line,
2443
+ found.kind === "colour" ? `Hard-coded colour \`${found.value}\` past the token layer (${found.utility}-[\u2026])` : `Hard-coded \`${found.value}\` past the token layer (${found.utility}-[\u2026])`,
2444
+ attr.classes.length > 80 ? `${attr.classes.slice(0, 77)}\u2026` : attr.classes
2445
+ )
2446
+ );
2447
+ }
2448
+ }
1436
2449
  return findings;
1437
2450
  }
1438
2451
  };
@@ -1446,7 +2459,7 @@ var MIN_LIGHTNESS = 8;
1446
2459
  var MAX_LIGHTNESS = 92;
1447
2460
  var violetBandHue = {
1448
2461
  name: "violet-band-hue",
1449
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2462
+ appliesTo: (file) => isStyleBearing(file),
1450
2463
  run(source, file, ctx) {
1451
2464
  const findings = [];
1452
2465
  const starts = buildLineIndex(source);
@@ -1489,7 +2502,7 @@ function getDetector(name) {
1489
2502
  }
1490
2503
 
1491
2504
  // src/check/run.ts
1492
- function runChecks(projectRoot, files, index, tokens, bucketFilter) {
2505
+ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
1493
2506
  const findings = [];
1494
2507
  const active = index.filter(
1495
2508
  (e) => !!e.detector && !!getDetector(e.detector)
@@ -1501,13 +2514,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
1501
2514
  if (applicable.length === 0) continue;
1502
2515
  let raw;
1503
2516
  try {
1504
- raw = readFileSync5(join7(projectRoot, file), "utf8");
2517
+ raw = readFileSync9(join14(projectRoot, file), "utf8");
1505
2518
  } catch {
1506
2519
  continue;
1507
2520
  }
1508
- const source = maskComments(raw);
2521
+ const source = maskComments(maskNonStyleRegions(raw, file));
1509
2522
  for (const { entry, detector } of applicable) {
1510
- const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens };
2523
+ const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
1511
2524
  findings.push(...detector.run(source, file, ctx));
1512
2525
  }
1513
2526
  }
@@ -1515,15 +2528,15 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
1515
2528
  }
1516
2529
 
1517
2530
  // src/check/tokens.ts
1518
- import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "fs";
1519
- import { join as join8 } from "path";
1520
- function loadTokenMap(installRoot) {
1521
- const tokensDir = join8(installRoot, ".jig", "tokens");
2531
+ import { existsSync as existsSync8, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "fs";
2532
+ import { join as join15 } from "path";
2533
+ function loadTokenMap(projectRoot) {
2534
+ const tokensDir = join15(projectRoot, ".jig", "tokens");
1522
2535
  const tokens = {};
1523
- if (!existsSync6(tokensDir)) return tokens;
1524
- const files = readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort();
2536
+ if (!existsSync8(tokensDir)) return tokens;
2537
+ const files = readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort();
1525
2538
  for (const file of files) {
1526
- const source = readFileSync6(join8(tokensDir, file), "utf8");
2539
+ const source = readFileSync10(join15(tokensDir, file), "utf8");
1527
2540
  for (const block of splitRuleBlocks(source)) {
1528
2541
  if (block.body.includes("{")) continue;
1529
2542
  if (block.selector.trim() !== ":root") continue;
@@ -1538,17 +2551,53 @@ function loadTokenMap(installRoot) {
1538
2551
  }
1539
2552
 
1540
2553
  // src/commands/check.ts
2554
+ var LEGACY_INDEX_REL = join16(".jig", "rules.index.json");
2555
+ function resolveIndexPath(projectRoot) {
2556
+ const legacy = join16(projectRoot, LEGACY_INDEX_REL);
2557
+ if (existsSync9(legacy)) return legacy;
2558
+ return join16(assetRoot(), "rules.index.json");
2559
+ }
2560
+ function resolveMode(projectRoot) {
2561
+ try {
2562
+ const config = JSON.parse(readFileSync11(join16(projectRoot, "jig.config.json"), "utf8"));
2563
+ const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
2564
+ if (modes.length === 1) return modes[0];
2565
+ return modes.length > 1 ? "mixed" : "unknown";
2566
+ } catch {
2567
+ return "unknown";
2568
+ }
2569
+ }
2570
+ var UNSUPPORTED_STYLE_HOSTS = [".elm", ".rs", ".templ", ".gohtml"];
2571
+ function summariseUnscanned(files) {
2572
+ const hit = files.filter((f) => !isStyleBearing(f) && hasExtension(f, UNSUPPORTED_STYLE_HOSTS));
2573
+ if (hit.length === 0) return void 0;
2574
+ const extensions = [...new Set(hit.map((f) => f.slice(f.lastIndexOf(".")).toLowerCase()))].sort();
2575
+ return { count: hit.length, extensions };
2576
+ }
2577
+ function countSpecs() {
2578
+ try {
2579
+ return loadSpecs(join16(assetRoot(), "rules")).length;
2580
+ } catch {
2581
+ return 0;
2582
+ }
2583
+ }
1541
2584
  function check(opts) {
1542
- const target = resolveInstalled(opts.projectRoot, opts.homeDir);
1543
- if (!target) {
1544
- throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
1545
- }
1546
- const indexPath = join9(target.installRoot, ".jig", "rules.index.json");
1547
- const index = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
1548
- const tokens = loadTokenMap(target.installRoot);
1549
- const { files } = selectFiles(opts.projectRoot, opts.all);
2585
+ const indexPath = resolveIndexPath(opts.projectRoot);
2586
+ const index = validateIndex(JSON.parse(readFileSync11(indexPath, "utf8")));
2587
+ const tokens = loadTokenMap(opts.projectRoot);
2588
+ const selection = selectFiles(opts.projectRoot, opts.all);
2589
+ const { files } = selection;
1550
2590
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
1551
- const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter);
2591
+ const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
2592
+ const projectParticipates = stylesheets.some((f) => {
2593
+ if (!hasExtension(f, CSS_EXTENSIONS)) return false;
2594
+ try {
2595
+ return participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
2596
+ } catch {
2597
+ return false;
2598
+ }
2599
+ });
2600
+ const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
1552
2601
  const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
1553
2602
  const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
1554
2603
  const noTokenLayer = (
@@ -1556,7 +2605,7 @@ function check(opts) {
1556
2605
  // told to add a token @import it has nowhere to put.
1557
2606
  cssFiles.length > 0 && cssFiles.every((f) => {
1558
2607
  try {
1559
- return !participatesInTokenLayer(readFileSync7(join9(opts.projectRoot, f), "utf8"), tokens);
2608
+ return !participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
1560
2609
  } catch {
1561
2610
  return true;
1562
2611
  }
@@ -1564,19 +2613,25 @@ function check(opts) {
1564
2613
  );
1565
2614
  const report = formatReport(findings, {
1566
2615
  totalRules: index.length,
2616
+ // From the CLI's own bundle, like `explain` — the spec count describes the
2617
+ // system, not whatever a project happens to have vendored. A legacy project
2618
+ // copy may have no `rules/` directory at all.
2619
+ totalSpecs: countSpecs(),
1567
2620
  version: opts.version,
1568
- noTokenLayer
2621
+ noTokenLayer,
2622
+ mode: resolveMode(opts.projectRoot),
2623
+ unscanned: summariseUnscanned(files)
1569
2624
  });
1570
2625
  return { findings, report, hasError };
1571
2626
  }
1572
2627
 
1573
2628
  // src/commands/init.ts
1574
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
1575
- import { dirname as dirname6, join as join13 } from "path";
2629
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
2630
+ import { dirname as dirname7, join as join20 } from "path";
1576
2631
 
1577
2632
  // src/init/detect.ts
1578
- import { readFileSync as readFileSync8 } from "fs";
1579
- import { join as join10 } from "path";
2633
+ import { readFileSync as readFileSync12 } from "fs";
2634
+ import { join as join17 } from "path";
1580
2635
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
1581
2636
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
1582
2637
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -1593,7 +2648,7 @@ var FRAMEWORK_DEPS = [
1593
2648
  function detectFramework(projectRoot) {
1594
2649
  let pkg;
1595
2650
  try {
1596
- pkg = JSON.parse(readFileSync8(join10(projectRoot, "package.json"), "utf8"));
2651
+ pkg = JSON.parse(readFileSync12(join17(projectRoot, "package.json"), "utf8"));
1597
2652
  } catch {
1598
2653
  return void 0;
1599
2654
  }
@@ -1611,7 +2666,7 @@ function detect(projectRoot) {
1611
2666
  for (const f of cssFiles) {
1612
2667
  let source;
1613
2668
  try {
1614
- source = readFileSync8(join10(projectRoot, f), "utf8");
2669
+ source = readFileSync12(join17(projectRoot, f), "utf8");
1615
2670
  } catch {
1616
2671
  continue;
1617
2672
  }
@@ -1628,9 +2683,26 @@ function detect(projectRoot) {
1628
2683
  return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
1629
2684
  }
1630
2685
 
2686
+ // src/init/gitignore.ts
2687
+ import { execFileSync as execFileSync2 } from "child_process";
2688
+ function isIgnored(projectRoot, relPath) {
2689
+ const candidates = relPath.endsWith("/") ? [relPath] : [relPath, `${relPath}/`];
2690
+ return candidates.some((path) => {
2691
+ try {
2692
+ execFileSync2("git", ["check-ignore", "-q", "--", path], {
2693
+ cwd: projectRoot,
2694
+ stdio: ["ignore", "ignore", "ignore"]
2695
+ });
2696
+ return true;
2697
+ } catch {
2698
+ return false;
2699
+ }
2700
+ });
2701
+ }
2702
+
1631
2703
  // src/init/derive.ts
1632
- import { readFileSync as readFileSync9 } from "fs";
1633
- import { join as join11 } from "path";
2704
+ import { readFileSync as readFileSync13 } from "fs";
2705
+ import { join as join18 } from "path";
1634
2706
  var DEFAULT_PROPOSAL = {
1635
2707
  h: 264,
1636
2708
  s: 0,
@@ -1663,7 +2735,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
1663
2735
  for (const file of cssFiles) {
1664
2736
  let source;
1665
2737
  try {
1666
- source = readFileSync9(join11(projectRoot, file), "utf8");
2738
+ source = readFileSync13(join18(projectRoot, file), "utf8");
1667
2739
  } catch {
1668
2740
  continue;
1669
2741
  }
@@ -1712,7 +2784,7 @@ function balancedBraceBody(source, openIdx) {
1712
2784
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
1713
2785
  let source;
1714
2786
  try {
1715
- source = readFileSync9(join11(projectRoot, tailwindConfigFile), "utf8");
2787
+ source = readFileSync13(join18(projectRoot, tailwindConfigFile), "utf8");
1716
2788
  } catch {
1717
2789
  return null;
1718
2790
  }
@@ -1768,7 +2840,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
1768
2840
  for (const file of cssFiles) {
1769
2841
  let source;
1770
2842
  try {
1771
- source = readFileSync9(join11(projectRoot, file), "utf8");
2843
+ source = readFileSync13(join18(projectRoot, file), "utf8");
1772
2844
  } catch {
1773
2845
  continue;
1774
2846
  }
@@ -1818,6 +2890,20 @@ function worstRatio(h, s, l) {
1818
2890
  const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
1819
2891
  return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
1820
2892
  }
2893
+ var DARK_BRAND_L = 88;
2894
+ var DARK_SURFACE_S = 6;
2895
+ var DARK_RAISED_L = 15;
2896
+ var DARK_BASE_L = 10;
2897
+ var DARK_FILL_ALPHA = 0.06;
2898
+ function darkWorstRatio(h, s) {
2899
+ const brand = toRgb(h, s, DARK_BRAND_L);
2900
+ const raised = toRgb(h, DARK_SURFACE_S, DARK_RAISED_L);
2901
+ const base = toRgb(h, DARK_SURFACE_S, DARK_BASE_L);
2902
+ const fill = compositeOver({ r: 255, g: 255, b: 255 }, DARK_FILL_ALPHA, base);
2903
+ const vsRaised = contrastRatio(brand, raised);
2904
+ const vsFill = contrastRatio(brand, fill);
2905
+ return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
2906
+ }
1821
2907
  function nearestPassingLightness(h, s, l) {
1822
2908
  if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
1823
2909
  for (let delta = 1; delta <= 100; delta++) {
@@ -1847,11 +2933,19 @@ function systemCollision(h, s, l) {
1847
2933
  return null;
1848
2934
  }
1849
2935
  function validateBrandColor(h, s, l) {
1850
- const { vsRaised, vsFill, worst } = worstRatio(h, s, l);
2936
+ const light = worstRatio(h, s, l);
2937
+ const dark = darkWorstRatio(h, s);
2938
+ const worst = Math.min(light.worst, dark.worst);
1851
2939
  const passesContrast = worst >= CONTRAST_FLOOR;
1852
2940
  return {
1853
- ratioVsRaised: vsRaised,
1854
- ratioVsFill: vsFill,
2941
+ ratioVsRaised: light.vsRaised,
2942
+ ratioVsFill: light.vsFill,
2943
+ lightRatioVsRaised: light.vsRaised,
2944
+ lightRatioVsFill: light.vsFill,
2945
+ lightWorstRatio: light.worst,
2946
+ darkRatioVsRaised: dark.vsRaised,
2947
+ darkRatioVsFill: dark.vsFill,
2948
+ darkWorstRatio: dark.worst,
1855
2949
  worstRatio: worst,
1856
2950
  passesContrast,
1857
2951
  nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
@@ -1868,7 +2962,7 @@ function round2(n) {
1868
2962
  }
1869
2963
  function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
1870
2964
  const fileName = `brand.${projectSlug}.css`;
1871
- const header = vendorHeader(fileName, version2, "css");
2965
+ const header = vendorHeader(fileName, version2, "css", null);
1872
2966
  if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
1873
2967
  throw new Error(
1874
2968
  `brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
@@ -1906,8 +3000,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
1906
3000
  }
1907
3001
 
1908
3002
  // src/init/project-name.ts
1909
- import { readFileSync as readFileSync10 } from "fs";
1910
- import { basename as basename2, join as join12 } from "path";
3003
+ import { readFileSync as readFileSync14 } from "fs";
3004
+ import { basename, join as join19 } from "path";
1911
3005
  function slugify(name) {
1912
3006
  const withoutScope = name.replace(/^@[^/]+\//, "");
1913
3007
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1915,11 +3009,11 @@ function slugify(name) {
1915
3009
  }
1916
3010
  function deriveProjectSlug(projectRoot) {
1917
3011
  try {
1918
- const pkg = JSON.parse(readFileSync10(join12(projectRoot, "package.json"), "utf8"));
3012
+ const pkg = JSON.parse(readFileSync14(join19(projectRoot, "package.json"), "utf8"));
1919
3013
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
1920
3014
  } catch {
1921
3015
  }
1922
- return slugify(basename2(projectRoot));
3016
+ return slugify(basename(projectRoot));
1923
3017
  }
1924
3018
 
1925
3019
  // src/commands/init.ts
@@ -1939,13 +3033,15 @@ function validateRounded(p) {
1939
3033
  }
1940
3034
  function describeValidation(v) {
1941
3035
  const lines = [];
3036
+ const modes = `light ${v.lightWorstRatio.toFixed(2)}:1, dark ${v.darkWorstRatio.toFixed(2)}:1`;
1942
3037
  if (v.passesContrast) {
1943
3038
  lines.push(
1944
- ` contrast OK \u2014 ${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill (floor 4.5:1)`
3039
+ ` contrast OK \u2014 ${modes} (floor 4.5:1; light is ${v.ratioVsRaised.toFixed(2)} vs --color-bg-raised and ${v.ratioVsFill.toFixed(2)} vs --color-fill)`
1945
3040
  );
1946
3041
  } else {
3042
+ const failing = v.lightWorstRatio < v.darkWorstRatio ? "light" : "dark";
1947
3043
  lines.push(
1948
- ` contrast FAILS \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill)`
3044
+ ` contrast FAILS in ${failing} mode \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${modes})`
1949
3045
  );
1950
3046
  if (v.nearestPassingLightness !== void 0) {
1951
3047
  lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
@@ -1979,7 +3075,7 @@ function parseSurfaces(answer, fallback) {
1979
3075
  return parsed.length > 0 ? parsed : fallback;
1980
3076
  }
1981
3077
  function fileState(projectRoot, absPath, relPath, initManifest) {
1982
- const existsOnDisk = existsSync7(absPath);
3078
+ const existsOnDisk = existsSync10(absPath);
1983
3079
  const tracked = !!initManifest && relPath in initManifest.files;
1984
3080
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
1985
3081
  return { existsOnDisk, tracked, modified };
@@ -1990,11 +3086,11 @@ function isValidSurfaceArray(v) {
1990
3086
  );
1991
3087
  }
1992
3088
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
1993
- if (!existsSync7(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3089
+ if (!existsSync10(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
1994
3090
  try {
1995
- const parsed = JSON.parse(readFileSync11(configAbsPath, "utf8"));
3091
+ const parsed = JSON.parse(readFileSync15(configAbsPath, "utf8"));
1996
3092
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
1997
- const brandResolves = brandCandidate ? existsSync7(join13(projectRoot, ...brandCandidate.split("/"))) : false;
3093
+ const brandResolves = brandCandidate ? existsSync10(join20(projectRoot, ...brandCandidate.split("/"))) : false;
1998
3094
  return {
1999
3095
  brand: brandResolves ? brandCandidate : fallbackBrand,
2000
3096
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -2020,7 +3116,7 @@ function insertAfterCharset(content, insertion) {
2020
3116
  return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
2021
3117
  }
2022
3118
  function wireImport(absPath, brandImport, modeImport, mode) {
2023
- const content = existsSync7(absPath) ? readFileSync11(absPath, "utf8") : "";
3119
+ const content = existsSync10(absPath) ? readFileSync15(absPath, "utf8") : "";
2024
3120
  const brandImportLine = `@import "${brandImport}";`;
2025
3121
  const modeImportLine = `@import "${modeImport}";`;
2026
3122
  const brandPresent = content.includes(brandImportLine);
@@ -2033,7 +3129,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
2033
3129
  const previousMode = existingModeMatch[2];
2034
3130
  const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
2035
3131
  ` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
2036
- writeFileSync5(absPath, next3, "utf8");
3132
+ writeFileSync6(absPath, next3, "utf8");
2037
3133
  return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
2038
3134
  }
2039
3135
  const brandIdx = content.indexOf(brandImportLine);
@@ -2041,7 +3137,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
2041
3137
  if (content[insertAt] === "\r") insertAt++;
2042
3138
  if (content[insertAt] === "\n") insertAt++;
2043
3139
  const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
2044
- writeFileSync5(absPath, next2, "utf8");
3140
+ writeFileSync6(absPath, next2, "utf8");
2045
3141
  return { status: "rewired", detail: `added missing mode import: ${mode}` };
2046
3142
  }
2047
3143
  const lines = `${brandImportLine}
@@ -2056,17 +3152,32 @@ ${modeImportLine}
2056
3152
  next = insertAfterCharset(content, content ? `${lines}
2057
3153
  ` : lines);
2058
3154
  }
2059
- mkdirSync5(dirname6(absPath), { recursive: true });
2060
- writeFileSync5(absPath, next, "utf8");
3155
+ mkdirSync6(dirname7(absPath), { recursive: true });
3156
+ writeFileSync6(absPath, next, "utf8");
2061
3157
  return { status: "wired" };
2062
3158
  }
2063
3159
  async function init(opts) {
2064
3160
  const log = opts.log ?? ((line) => console.log(line));
2065
3161
  const prompt = opts.prompt ?? defaultPrompt;
2066
- const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
2067
- if (!resolved) {
2068
- throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
2069
- }
3162
+ const migrateLegacy = async (report, describe) => {
3163
+ if (!report.present) return;
3164
+ for (const line of describe(report)) log(line);
3165
+ const removable = removableLegacyFiles(report);
3166
+ if (removable.length === 0) return;
3167
+ if (opts.yes) {
3168
+ log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
3169
+ return;
3170
+ }
3171
+ const answer = (await prompt(` Remove these ${removable.length} unedited legacy file(s)? [y/N]: `)).toLowerCase();
3172
+ if (answer === "y" || answer === "yes") {
3173
+ removeLegacyFiles(opts.projectRoot, removable);
3174
+ log(` Removed ${removable.length} file(s).`);
3175
+ } else {
3176
+ log(" Left them in place.");
3177
+ }
3178
+ };
3179
+ await migrateLegacy(detectLegacyRules(opts.projectRoot), describeLegacyReport);
3180
+ await migrateLegacy(detectLegacyCursorRules(opts.projectRoot), describeLegacyCursorReport);
2070
3181
  const detection = detect(opts.projectRoot);
2071
3182
  log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
2072
3183
  if (detection.cssFiles.length === 0) {
@@ -2107,6 +3218,11 @@ async function init(opts) {
2107
3218
  );
2108
3219
  }
2109
3220
  }
3221
+ if (opts.yes) {
3222
+ log(
3223
+ `Surface \u2192 mode: ${surfaces.map((s) => `'${s.match}' \u2192 ${s.mode}`).join(", ")} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
3224
+ );
3225
+ }
2110
3226
  if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
2111
3227
  const originalL = proposal.l;
2112
3228
  proposal = { ...proposal, l: validation.nearestPassingLightness };
@@ -2119,9 +3235,9 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
2119
3235
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
2120
3236
  const projectSlug = deriveProjectSlug(opts.projectRoot);
2121
3237
  const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
2122
- const brandAbsPath = join13(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
3238
+ const brandAbsPath = join20(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
2123
3239
  const configRelPath = "jig.config.json";
2124
- const configAbsPath = join13(opts.projectRoot, configRelPath);
3240
+ const configAbsPath = join20(opts.projectRoot, configRelPath);
2125
3241
  const initManifest = readInitManifest(opts.projectRoot);
2126
3242
  const files = { ...initManifest?.files ?? {} };
2127
3243
  const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
@@ -2150,68 +3266,72 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
2150
3266
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
2151
3267
  }
2152
3268
  if (brandAction === "written") {
2153
- const defaultCssRaw = readFileSync11(join13(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3269
+ const defaultCssRaw = readFileSync15(join20(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
2154
3270
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
2155
- mkdirSync5(dirname6(brandAbsPath), { recursive: true });
2156
- writeFileSync5(brandAbsPath, content, "utf8");
3271
+ mkdirSync6(dirname7(brandAbsPath), { recursive: true });
3272
+ writeFileSync6(brandAbsPath, content, "utf8");
2157
3273
  files[brandRelPath] = checksum(content);
2158
3274
  }
2159
3275
  if (configAction === "written") {
2160
3276
  const config = { brand: brandRelPath, surfaces };
2161
3277
  const content = `${JSON.stringify(config, null, 2)}
2162
3278
  `;
2163
- writeFileSync5(configAbsPath, content, "utf8");
3279
+ writeFileSync6(configAbsPath, content, "utf8");
2164
3280
  files[configRelPath] = checksum(content);
2165
3281
  } else if (configAction === "merged") {
2166
3282
  let existing = {};
2167
3283
  try {
2168
- existing = JSON.parse(readFileSync11(configAbsPath, "utf8"));
3284
+ existing = JSON.parse(readFileSync15(configAbsPath, "utf8"));
2169
3285
  } catch {
2170
3286
  existing = {};
2171
3287
  }
2172
3288
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
2173
- const brandStillResolves = existingBrand ? existsSync7(join13(opts.projectRoot, ...existingBrand.split("/"))) : false;
3289
+ const brandStillResolves = existingBrand ? existsSync10(join20(opts.projectRoot, ...existingBrand.split("/"))) : false;
2174
3290
  const merged = {
2175
3291
  brand: brandStillResolves ? existingBrand : brandRelPath,
2176
3292
  surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
2177
3293
  };
2178
3294
  const content = `${JSON.stringify(merged, null, 2)}
2179
3295
  `;
2180
- writeFileSync5(configAbsPath, content, "utf8");
3296
+ writeFileSync6(configAbsPath, content, "utf8");
2181
3297
  files[configRelPath] = checksum(content);
2182
3298
  }
2183
3299
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
2184
3300
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
2185
- const wiringBrandAbsPath = join13(opts.projectRoot, ...effectiveConfig.brand.split("/"));
2186
- const modeFileName = `mode.${primaryMode}.css`;
2187
- const modeAbsPath = join13(opts.projectRoot, ".jig", "tokens", modeFileName);
2188
- if (resolved.scope === "global") {
3301
+ const wiringBrandAbsPath = join20(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3302
+ const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
3303
+ const modeAbsPaths = {};
3304
+ for (const mode of declaredModes) {
3305
+ const modeFileName = `mode.${mode}.css`;
3306
+ const modeAbsPath2 = join20(opts.projectRoot, ".jig", "tokens", modeFileName);
3307
+ modeAbsPaths[mode] = modeAbsPath2;
2189
3308
  const modeRelPath = relKey(".jig", "tokens", modeFileName);
2190
- const modeState = fileState(opts.projectRoot, modeAbsPath, modeRelPath, initManifest);
3309
+ const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
2191
3310
  let modeAction = "written";
2192
3311
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
2193
3312
  else if (modeState.modified) modeAction = "skipped-edited";
2194
3313
  if (modeAction === "written") {
2195
- const modeSourceRaw = readFileSync11(join13(opts.packageRoot, "tokens", modeFileName), "utf8");
2196
- const content = vendorHeader(modeFileName, opts.version, "css") + modeSourceRaw;
2197
- mkdirSync5(dirname6(modeAbsPath), { recursive: true });
2198
- writeFileSync5(modeAbsPath, content, "utf8");
3314
+ const modeSourceRaw = readFileSync15(join20(opts.packageRoot, "tokens", modeFileName), "utf8");
3315
+ const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
3316
+ mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
3317
+ writeFileSync6(modeAbsPath2, content, "utf8");
2199
3318
  files[modeRelPath] = checksum(content);
2200
3319
  } else {
2201
3320
  log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
2202
3321
  }
2203
3322
  }
2204
- writeInitManifest(opts.projectRoot, { files });
3323
+ const modeAbsPath = modeAbsPaths[primaryMode];
3324
+ writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
2205
3325
  const wireTarget = findWireTarget(detection);
2206
3326
  let wiring;
2207
3327
  if (wireTarget) {
2208
- const targetAbsDir = dirname6(join13(opts.projectRoot, wireTarget));
3328
+ const targetAbsDir = dirname7(join20(opts.projectRoot, wireTarget));
2209
3329
  const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
2210
3330
  const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
2211
3331
  const snippet = `@import "${brandImport}";
2212
3332
  @import "${modeImport}";`;
2213
3333
  try {
2214
- const outcome = wireImport(join13(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
3334
+ const outcome = wireImport(join20(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
2215
3335
  wiring = { target: wireTarget, status: outcome.status, snippet };
2216
3336
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
2217
3337
  log(`
@@ -2231,11 +3351,29 @@ Could not write to ${wireTarget}: ${err.message}`);
2231
3351
  const snippet = `@import "${brandImport}";
2232
3352
  @import "${modeImport}";`;
2233
3353
  wiring = { target: null, status: "print-only", snippet };
3354
+ log("\nCould not find a single unambiguous stylesheet to wire the import into.");
3355
+ const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
3356
+ if (pasteTargets.length > 0) {
3357
+ log("Paste the block for whichever of these is your global stylesheet:");
3358
+ for (const target of pasteTargets) {
3359
+ const dir = dirname7(join20(opts.projectRoot, target));
3360
+ const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
3361
+ @import "${relativeImportPath(dir, modeAbsPath)}";`;
3362
+ log(`
3363
+ ${target}:`);
3364
+ log(` ${forTarget.split("\n").join("\n ")}`);
3365
+ }
3366
+ } else {
3367
+ log(
3368
+ "Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so these project-root paths need a ../ per directory of depth:"
3369
+ );
3370
+ log(` ${snippet.split("\n").join("\n ")}`);
3371
+ }
3372
+ }
3373
+ if (isIgnored(opts.projectRoot, ".jig")) {
2234
3374
  log(
2235
- `
2236
- Could not find a single unambiguous stylesheet to wire the import into. Add this near the top of your global stylesheet (paths shown relative to the project root \u2014 adjust to wherever you paste them):`
3375
+ "\n WARNING: .jig/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the .jig/ rule from .gitignore, or commit the directory explicitly."
2237
3376
  );
2238
- log(` ${snippet.split("\n").join("\n ")}`);
2239
3377
  }
2240
3378
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
2241
3379
  log("\nBaseline:");
@@ -2259,7 +3397,13 @@ Could not find a single unambiguous stylesheet to wire the import into. Add this
2259
3397
 
2260
3398
  // src/index.ts
2261
3399
  var packageRoot = getPackageRoot();
2262
- var { version } = JSON.parse(readFileSync12(join14(packageRoot, "package.json"), "utf8"));
3400
+ var { version } = JSON.parse(readFileSync16(join21(packageRoot, "package.json"), "utf8"));
3401
+ function warnIfUnpublishedPin() {
3402
+ if (isPublishedBuild(packageRoot)) return;
3403
+ console.warn(
3404
+ `Note: this is a source build, so the skill pins 'npx jig-ui@${version}' \u2014 a version that may not be published. Agents reading it will not be able to run the CLI until it is. Re-run install or update from a published build to correct the pin.`
3405
+ );
3406
+ }
2263
3407
  var program = new Command();
2264
3408
  program.name("jig").description("A design system for coding agents.").version(version);
2265
3409
  program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
@@ -2277,7 +3421,12 @@ program.command("install").description("Install Jig rules and the agent skill fi
2277
3421
  version,
2278
3422
  homeDir: homedir()
2279
3423
  });
3424
+ if (result.warning) {
3425
+ console.warn(result.warning);
3426
+ return;
3427
+ }
2280
3428
  console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
3429
+ warnIfUnpublishedPin();
2281
3430
  for (const f of result.written) console.log(` + ${f}`);
2282
3431
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
2283
3432
  } catch (err) {
@@ -2285,6 +3434,14 @@ program.command("install").description("Install Jig rules and the agent skill fi
2285
3434
  process.exit(1);
2286
3435
  }
2287
3436
  });
3437
+ program.command("explain").description("Print a rule's full text, its correction, and the version it was introduced in.").argument("<rule-id>", "a rule id, e.g. 'C-19' or 'P-02'").action((ruleId) => {
3438
+ try {
3439
+ console.log(explain({ ruleId, version }));
3440
+ } catch (err) {
3441
+ console.error(err.message);
3442
+ process.exit(1);
3443
+ }
3444
+ });
2288
3445
  program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
2289
3446
  const projectRoot = findProjectRoot(process.cwd());
2290
3447
  try {
@@ -2296,7 +3453,11 @@ program.command("update").description("Update vendored Jig rules, skipping files
2296
3453
  version,
2297
3454
  homeDir: homedir()
2298
3455
  });
2299
- console.log(`Updated Jig ${result.fromVersion} \u2192 ${result.toVersion}`);
3456
+ const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
3457
+ warnIfUnpublishedPin();
3458
+ console.log(
3459
+ `Updated Jig \u2192 ${result.toVersion} in ${result.targets.length} harness${result.targets.length === 1 ? "" : "es"}: ${label}`
3460
+ );
2300
3461
  for (const f of result.updated) console.log(` ~ ${f}`);
2301
3462
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
2302
3463
  } catch (err) {