@lemoncode/lemony 0.5.0 → 0.6.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.
Files changed (32) hide show
  1. package/README.md +17 -16
  2. package/catalog/VERSION +1 -1
  3. package/catalog/agents/implementer.md +36 -1
  4. package/catalog/agents/orchestrator.md +206 -83
  5. package/catalog/agents/reviewer.md +8 -3
  6. package/catalog/agents/spinoff.md +3 -2
  7. package/catalog/agents/ui-design.md +3 -1
  8. package/catalog/agents/ui-designer.md +6 -3
  9. package/catalog/commands/pause.md +50 -5
  10. package/catalog/commands/resume.md +13 -2
  11. package/catalog/commands/spinoff.md +5 -3
  12. package/catalog/commands/sync-design-tokens.md +6 -2
  13. package/catalog/harness.config.schema.json +4 -0
  14. package/catalog/hooks/init.sh +80 -7
  15. package/catalog/hooks/lib/live-branch.sh +37 -0
  16. package/catalog/hooks/lib/merge-pr.sh +17 -6
  17. package/catalog/hooks/lib/playbook-scan.sh +4 -2
  18. package/catalog/hooks/session-close.sh +26 -7
  19. package/catalog/schemas/tier2-events-history.md +33 -2
  20. package/catalog/schemas/tier2-events.md +30 -22
  21. package/catalog/skills/build-ui/SKILL.md +5 -2
  22. package/catalog/skills/design-tool-sync/SKILL.md +48 -7
  23. package/catalog/skills/grill-ui/SKILL.md +4 -1
  24. package/catalog/skills/grill-ui/ui-handoff-format.md +2 -1
  25. package/catalog/skills/mutation-testing/SKILL.md +22 -8
  26. package/catalog/skills/prd-to-spec/SKILL.md +27 -5
  27. package/catalog/skills/review-pr/SKILL.md +7 -1
  28. package/catalog/skills/review-pr/reference.md +3 -2
  29. package/catalog/templates/claude-code/agents.md.tpl +2 -1
  30. package/catalog/templates/claude-code/harness.config.yml.tpl +12 -3
  31. package/dist/cli.mjs +2579 -775
  32. package/package.json +11 -10
package/dist/cli.mjs CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  import { access, appendFile, chmod, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
4
- import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
4
+ import { basename, delimiter, dirname, extname, join, posix, relative, resolve, sep } from "node:path";
5
5
  import { argv, cwd, env, exit, stderr, stdin, stdout } from "node:process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { promisify } from "node:util";
8
- import { parse, parseDocument } from "yaml";
8
+ import { isMap, isPair, isScalar, parse, parseDocument, visit } from "yaml";
9
+ import { constants } from "node:fs";
9
10
  import { z } from "zod";
10
11
  import { createHash, randomBytes } from "node:crypto";
11
- import { constants } from "node:fs";
12
+ import MarkdownIt from "markdown-it";
12
13
  //#region src/events/append.ts
13
14
  const appendEvent = async (eventsPath, event) => {
14
15
  await mkdir(dirname(eventsPath), { recursive: true });
@@ -17,6 +18,8 @@ const appendEvent = async (eventsPath, event) => {
17
18
  //#endregion
18
19
  //#region src/config/config.constant.ts
19
20
  const HARNESS_CONFIG_FILENAME = "harness.config.yml";
21
+ const CONFIG_MALFORMED_MESSAGE = `${HARNESS_CONFIG_FILENAME} is empty or malformed.`;
22
+ const NULL_BLOCK_SENTINEL = "lemony-null-block-comment-";
20
23
  const DEPRECATED_CONFIG_KEYS = ["profile", "agents"];
21
24
  const DEPRECATED_PATHS_KEYS = [
22
25
  "state",
@@ -28,7 +31,8 @@ const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
28
31
  const CONFIG_KEY_SINCE = {
29
32
  gates: "0.3.0",
30
33
  "implementation.auto_commit": "0.4.0",
31
- "design_tokens.verify": "0.5.0"
34
+ "design_tokens.verify": "0.5.0",
35
+ "design_tokens.file": "0.6.0"
32
36
  };
33
37
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
34
38
  const TARGETS = ["claude-code"];
@@ -47,6 +51,236 @@ const PATHS_DEFAULTS = {
47
51
  playbooks_global: "~/.claude/playbooks"
48
52
  };
49
53
  //#endregion
54
+ //#region src/fs/exists.ts
55
+ const pathExists = async (path) => {
56
+ try {
57
+ await stat(path);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ };
63
+ const entryExists = async (path) => {
64
+ try {
65
+ await lstat(path);
66
+ return true;
67
+ } catch (error) {
68
+ if (error.code === "ENOENT") return false;
69
+ throw error;
70
+ }
71
+ };
72
+ //#endregion
73
+ //#region src/fs/list-files.ts
74
+ const listFiles = async (dir) => {
75
+ const entries = await readdir(dir, { withFileTypes: true });
76
+ return (await Promise.all(entries.map(async (entry) => {
77
+ if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
78
+ return [entry.name];
79
+ }))).flat();
80
+ };
81
+ //#endregion
82
+ //#region src/fs/prune-empty-dirs.ts
83
+ const pruneEmptyDirs = async (repoRoot, removedFiles) => {
84
+ const dirs = /* @__PURE__ */ new Set();
85
+ for (const relPath of removedFiles) {
86
+ let dir = dirname(relPath);
87
+ while (dir !== "." && dir !== sep) {
88
+ dirs.add(dir);
89
+ dir = dirname(dir);
90
+ }
91
+ }
92
+ const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
93
+ for (const dir of ordered) try {
94
+ await rmdir(join(repoRoot, dir));
95
+ } catch {}
96
+ };
97
+ //#endregion
98
+ //#region src/fs/fs.constant.ts
99
+ const SPECIAL_FILE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not read.";
100
+ const DANGLING_WRITE_REFUSAL = "the entry is a symlink whose target is missing, so it was not written (writing through it would create the file it points at).";
101
+ const SPECIAL_FILE_WRITE_REFUSAL = "the entry is not a regular file (a FIFO, a socket or a device), so it was not written.";
102
+ //#endregion
103
+ //#region src/fs/read-regular-file.ts
104
+ const readRegularFile = async (path) => {
105
+ let handle;
106
+ try {
107
+ handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
108
+ } catch (error) {
109
+ const target = await stat(path).catch(() => void 0);
110
+ if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL, { cause: error });
111
+ throw error;
112
+ }
113
+ try {
114
+ const target = await handle.stat();
115
+ if (!target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_REFUSAL);
116
+ return await handle.readFile("utf8");
117
+ } finally {
118
+ await handle.close();
119
+ }
120
+ };
121
+ //#endregion
122
+ //#region src/fs/write-managed.ts
123
+ const writeManaged = async (root, relPath, content, executable) => {
124
+ const dest = join(root, relPath);
125
+ await mkdir(dirname(dest), { recursive: true });
126
+ await writeFile(dest, content);
127
+ await chmod(dest, executable ? 493 : 420);
128
+ };
129
+ //#endregion
130
+ //#region src/fs/write-regular-file.ts
131
+ const writeRegularFile = async (path, text) => {
132
+ let handle;
133
+ try {
134
+ handle = await openForWrite(path);
135
+ } catch (error) {
136
+ const target = await stat(path).catch(() => void 0);
137
+ if (target && !target.isFile() && !target.isDirectory()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL, { cause: error });
138
+ throw error;
139
+ }
140
+ try {
141
+ if (!(await handle.stat()).isFile()) throw new Error(SPECIAL_FILE_WRITE_REFUSAL);
142
+ await handle.writeFile(text, "utf8");
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ };
147
+ const WRITE = constants.O_WRONLY | constants.O_TRUNC | constants.O_NONBLOCK;
148
+ const openForWrite = async (path) => {
149
+ try {
150
+ return await open(path, WRITE);
151
+ } catch (error) {
152
+ if (error.code !== "ENOENT") throw error;
153
+ }
154
+ try {
155
+ return await open(path, WRITE | constants.O_CREAT | constants.O_EXCL);
156
+ } catch (error) {
157
+ if (error.code !== "EEXIST") throw error;
158
+ if ((await lstat(path).catch(() => void 0))?.isSymbolicLink()) throw new Error(DANGLING_WRITE_REFUSAL, { cause: error });
159
+ }
160
+ return await open(path, WRITE);
161
+ };
162
+ //#endregion
163
+ //#region src/fs/symlink-guard.ts
164
+ const assertNoSymlinkTraversal = async (root, relPaths) => {
165
+ const offenders = await findSymlinkTraversals(root, relPaths);
166
+ if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
167
+ };
168
+ const findSymlinkTraversals = async (root, relPaths) => {
169
+ const components = /* @__PURE__ */ new Set();
170
+ for (const relPath of relPaths) {
171
+ let current = "";
172
+ for (const part of relPath.split(sep)) {
173
+ if (part.length === 0) continue;
174
+ current = current ? join(current, part) : part;
175
+ components.add(current);
176
+ }
177
+ }
178
+ return (await Promise.all([...components].map(async (rel) => {
179
+ try {
180
+ return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }))).filter((rel) => rel !== null);
185
+ };
186
+ //#endregion
187
+ //#region src/paths/claude-paths.constant.ts
188
+ const CLAUDE_DIR = ".claude";
189
+ const STATE_DIR = join(CLAUDE_DIR, "state");
190
+ const TASKS_DIR = join(STATE_DIR, "tasks");
191
+ const SKILLS_DIR = join(CLAUDE_DIR, "skills");
192
+ const AGENTS_DIR = join(CLAUDE_DIR, "agents");
193
+ const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
194
+ const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
195
+ const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
196
+ const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
197
+ const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
198
+ const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
199
+ const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
200
+ //#endregion
201
+ //#region src/config/classify-config-link.ts
202
+ const LINK_FAULTS = /* @__PURE__ */ new Map([
203
+ ["ENOENT", "target-missing"],
204
+ ["ENOTDIR", "target-missing"],
205
+ ["ENAMETOOLONG", "target-missing"],
206
+ ["ELOOP", "loop"],
207
+ ["EACCES", "target-unreadable"]
208
+ ]);
209
+ const classifyConfigLinkFault = async (configPath, readError) => {
210
+ const code = readError?.code;
211
+ const fault = code === void 0 ? void 0 : LINK_FAULTS.get(code);
212
+ if (fault === void 0) return void 0;
213
+ return await lstat(configPath).then((entry) => entry.isSymbolicLink(), () => false) ? fault : void 0;
214
+ };
215
+ //#endregion
216
+ //#region src/baseline/baseline.ts
217
+ const STAGING_DIR$1 = ".staging";
218
+ const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
219
+ const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
220
+ const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
221
+ const writeBaseline = async (repoRoot, version, files) => {
222
+ const root = baselineRootDir(repoRoot);
223
+ const staging = join(root, STAGING_DIR$1);
224
+ await rm(staging, {
225
+ recursive: true,
226
+ force: true
227
+ });
228
+ await mkdir(staging, { recursive: true });
229
+ await Promise.all(files.map(async ({ relPath, content }) => {
230
+ const dest = join(staging, relPath);
231
+ await mkdir(dirname(dest), { recursive: true });
232
+ await writeFile(dest, content);
233
+ }));
234
+ const versionDir = baselineVersionDir(repoRoot, version);
235
+ await rm(versionDir, {
236
+ recursive: true,
237
+ force: true
238
+ });
239
+ await rename(staging, versionDir);
240
+ await pruneOtherVersions(root, version);
241
+ };
242
+ const pruneOtherVersions = async (root, keep) => {
243
+ const entries = await readdir(root, { withFileTypes: true });
244
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
245
+ recursive: true,
246
+ force: true
247
+ })));
248
+ };
249
+ const readBaseline = async (repoRoot, version) => {
250
+ const versionDir = baselineVersionDir(repoRoot, version);
251
+ let relPaths;
252
+ try {
253
+ relPaths = await listFiles(versionDir);
254
+ } catch (error) {
255
+ if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
256
+ throw error;
257
+ }
258
+ const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
259
+ return new Map(entries);
260
+ };
261
+ const findBaselineVersion = async (repoRoot) => {
262
+ const root = baselineRootDir(repoRoot);
263
+ let entries;
264
+ try {
265
+ entries = await readdir(root, { withFileTypes: true });
266
+ } catch (error) {
267
+ if (isENOENT$1(error)) return null;
268
+ throw error;
269
+ }
270
+ const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
271
+ if (versions.length <= 1) return versions[0] ?? null;
272
+ const withMtime = await Promise.all(versions.map(async (version) => ({
273
+ version,
274
+ mtimeMs: (await stat(join(root, version))).mtimeMs
275
+ })));
276
+ withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
277
+ return withMtime.at(-1)?.version ?? null;
278
+ };
279
+ //#endregion
280
+ //#region src/config/has-install-baseline.ts
281
+ const hasInstallBaseline = async (repoRoot) => findBaselineVersion(repoRoot).then((version) => version !== null, (error) => PERMISSION_CODES.has(error.code ?? ""));
282
+ const PERMISSION_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
283
+ //#endregion
50
284
  //#region src/config/config-error.ts
51
285
  const formatConfigError = (schema, error) => {
52
286
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
@@ -110,6 +344,16 @@ const levenshtein = (a, b) => {
110
344
  return prev[b.length] ?? 0;
111
345
  };
112
346
  //#endregion
347
+ //#region src/config/repo-relative-path.ts
348
+ const repoRelativePathIssue = (value) => {
349
+ if (value.includes("\\")) return `must use "/" as the separator, got "${value}".`;
350
+ if (posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) return `must be relative to the repository root, got the absolute path "${value}".`;
351
+ const normalized = posix.normalize(value);
352
+ if (normalized === ".." || normalized.startsWith("../")) return `must stay inside the repository, got "${value}", which leads out of it.`;
353
+ if (normalized === "." || normalized === "./") return `must name a file inside the repository, got "${value}", which is the repository root.`;
354
+ if (value.endsWith("/") || posix.basename(value) === ".") return `must name a file, got "${value}", which names a directory.`;
355
+ };
356
+ //#endregion
113
357
  //#region src/config/config.schema.ts
114
358
  const pathsSchema = z.object({
115
359
  playbooks: z.string().default(PATHS_DEFAULTS.playbooks),
@@ -123,7 +367,15 @@ const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z
123
367
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
124
368
  const designTokensSchema = z.object({
125
369
  scan_extensions: z.array(z.string()).default([]),
126
- verify: z.string().trim().min(1).optional()
370
+ verify: z.string().trim().min(1).optional(),
371
+ file: z.string().trim().min(1, "must name the token file, relative to the repository root.").superRefine((value, ctx) => {
372
+ if (value.length === 0) return;
373
+ const issue = repoRelativePathIssue(value);
374
+ if (issue !== void 0) ctx.addIssue({
375
+ code: "custom",
376
+ message: issue
377
+ });
378
+ }).optional()
127
379
  }).strict().prefault({});
128
380
  const mergeSchema = z.object({
129
381
  checks_timeout_secs: z.int().positive().default(600),
@@ -145,24 +397,31 @@ const harnessConfigSchema = z.object({
145
397
  }).strict();
146
398
  //#endregion
147
399
  //#region src/config/config.ts
400
+ const OPTIONAL_CONFIG_KEYS = Object.entries(harnessConfigSchema.shape).filter(([, schema]) => schema.safeParse(void 0).success).map(([key]) => key);
148
401
  const readHarnessConfig = async (repoRoot) => {
149
402
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
150
403
  let raw;
151
404
  try {
152
- raw = await readFile(configPath, "utf8");
405
+ raw = await readRegularFile(configPath);
153
406
  } catch (cause) {
154
- if (cause.code === "ENOENT") throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
407
+ const linkFault = await classifyConfigLinkFault(configPath, cause);
408
+ if (linkFault !== void 0) throw new Error(configLinkMessage(repoRoot, linkFault), { cause });
409
+ if (cause.code === "ENOENT") {
410
+ if (await hasInstallBaseline(repoRoot)) throw new Error(missingBesideBaselineMessage(repoRoot), { cause });
411
+ throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
412
+ }
155
413
  throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
156
414
  }
157
415
  let parsed;
158
416
  try {
159
417
  parsed = parse(raw);
160
418
  } catch (cause) {
161
- throw new Error(`${HARNESS_CONFIG_FILENAME} is empty or malformed.`, { cause });
419
+ throw new Error(CONFIG_MALFORMED_MESSAGE, { cause });
162
420
  }
163
- if (!parsed || typeof parsed !== "object") throw new Error(`${HARNESS_CONFIG_FILENAME} is empty or malformed.`);
421
+ if (typeof parsed !== "object" || parsed === null || Object.getPrototypeOf(parsed) !== Object.prototype) throw new Error(CONFIG_MALFORMED_MESSAGE);
164
422
  const candidate = { ...parsed };
165
423
  for (const key of DEPRECATED_CONFIG_KEYS) delete candidate[key];
424
+ for (const key of OPTIONAL_CONFIG_KEYS) if (candidate[key] === null) delete candidate[key];
166
425
  const paths = candidate.paths;
167
426
  if (paths && typeof paths === "object" && !Array.isArray(paths)) {
168
427
  const pathsCandidate = { ...paths };
@@ -179,6 +438,26 @@ const readHarnessConfig = async (repoRoot) => {
179
438
  if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
180
439
  return result.data;
181
440
  };
441
+ const missingBesideBaselineMessage = (repoRoot) => `${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}, but ${BASELINE_ROOT_REL} exists: the harness was installed here, or an install did not finish. If a \`lemony install\` did not finish here, re-run it; otherwise restore ${HARNESS_CONFIG_FILENAME} from git history or a backup (\`lemony install\` would, with no TTY, replace the files you edited with the vendor copy).`;
442
+ const REPOINT_CONFIG_LINK = `Repoint it at the config file, or replace it with a copy of the config (from git history or a backup); remove it and run \`lemony install\` only in a repo that was never installed.`;
443
+ const configLinkMessage = (repoRoot, fault) => {
444
+ const prefix = `Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}:`;
445
+ switch (fault) {
446
+ case "target-missing": return `${prefix} the entry is there but its target is missing (a broken symlink?). ${REPOINT_CONFIG_LINK}`;
447
+ case "loop": return `${prefix} it is a symlink that loops or runs through too many links (following it never reaches a file). ${REPOINT_CONFIG_LINK}`;
448
+ case "target-unreadable": return `${prefix} it is a symlink whose target cannot be read (permission denied). Give your user read access to the target and search access to each directory on its path, or repoint the link at a config file you can read.`;
449
+ }
450
+ };
451
+ const readOptionalHarnessConfig = async (repoRoot) => {
452
+ let present;
453
+ try {
454
+ present = await entryExists(join(repoRoot, HARNESS_CONFIG_FILENAME));
455
+ } catch (error) {
456
+ throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
457
+ }
458
+ if (!present && !await hasInstallBaseline(repoRoot)) return void 0;
459
+ return readHarnessConfig(repoRoot);
460
+ };
182
461
  //#endregion
183
462
  //#region src/config/compare-version.ts
184
463
  const PRERELEASE_RANK = {
@@ -235,6 +514,34 @@ const setConfigValues = (rawYaml, updates) => {
235
514
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
236
515
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
237
516
  for (const key of DEPRECATED_IMPLEMENTATION_KEYS) if (doc.hasIn(["implementation", key])) doc.deleteIn(["implementation", key]);
517
+ return restoreNullBlockComments(rawYaml, doc);
518
+ };
519
+ const restoreNullBlockComments = (rawYaml, doc) => {
520
+ const restored = [];
521
+ visit(doc, { Pair(index, pair, path) {
522
+ const { key, value } = pair;
523
+ const parent = path.at(-1);
524
+ if (!isMap(parent) || parent.flow || !isScalar(key) || !key.range) return;
525
+ if (!isScalar(value) || value.value !== null || !value.range) return;
526
+ if (value.range[0] !== value.range[1] || value.anchor || value.tag) return;
527
+ if (value.comment === void 0 || value.comment === null) return;
528
+ const colon = rawYaml.indexOf(":", key.range[1]) + 1;
529
+ if (!/^[ \t]*$/.test(rawYaml.slice(key.range[1], colon - 1))) return;
530
+ const source = rawYaml.slice(colon, value.range[2]).replace(/[ \t]+$/, "");
531
+ restored.push({
532
+ value,
533
+ comment: value.comment,
534
+ source: source.replaceAll("\r\n", "\n")
535
+ });
536
+ value.comment = `${NULL_BLOCK_SENTINEL}${restored.length - 1}`;
537
+ const commentEnd = colon + source.trimEnd().length;
538
+ const gap = /^\s*/.exec(rawYaml.slice(commentEnd))?.[0] ?? "";
539
+ const next = parent.items[index + 1];
540
+ if (isPair(next) && isScalar(next.key) && next.key.range?.[0] === commentEnd + gap.length && !/\n[ \t\r]*\n/.test(gap)) next.key.spaceBefore = false;
541
+ } });
542
+ const text = restored.reduce((current, { source }, index) => current.replace(`: #${NULL_BLOCK_SENTINEL}${index}\n`, `:${source.endsWith("\n") ? source : `${source}\n`}`), doc.toString());
543
+ if (!text.includes("lemony-null-block-comment-")) return text;
544
+ for (const { value, comment } of restored) value.comment = comment;
238
545
  return doc.toString();
239
546
  };
240
547
  const writeConfigValues = async (repoRoot, updates) => {
@@ -266,20 +573,6 @@ const pointerFrontmatterSchema = z.object({
266
573
  });
267
574
  Object.keys(pointerFrontmatterSchema.shape);
268
575
  //#endregion
269
- //#region src/paths/claude-paths.constant.ts
270
- const CLAUDE_DIR = ".claude";
271
- const STATE_DIR = join(CLAUDE_DIR, "state");
272
- const TASKS_DIR = join(STATE_DIR, "tasks");
273
- const SKILLS_DIR = join(CLAUDE_DIR, "skills");
274
- const AGENTS_DIR = join(CLAUDE_DIR, "agents");
275
- const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
276
- const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
277
- const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
278
- const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
279
- const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
280
- const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
281
- const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
282
- //#endregion
283
576
  //#region src/events/envelope.ts
284
577
  const buildEnvelope = (input) => {
285
578
  const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
@@ -392,7 +685,8 @@ const taskDoneSchema = envelopeBase.extend({
392
685
  cycle_time_h: z.number().nonnegative().finite(),
393
686
  review_rejections: z.number().int().nonnegative(),
394
687
  mode: z.enum(IMPLEMENTATION_MODES).optional(),
395
- steps: z.number().int().positive().optional()
688
+ steps: z.number().int().positive().optional(),
689
+ checkpoints: z.number().int().positive().optional()
396
690
  }).strict();
397
691
  const reviewRejectedSchema = envelopeBase.extend({
398
692
  type: z.literal("review_rejected"),
@@ -439,6 +733,16 @@ const eventSchema = z.discriminatedUnion("type", [
439
733
  stepCompletedSchema
440
734
  ]);
441
735
  //#endregion
736
+ //#region src/events/task-done-count-issues.ts
737
+ const taskDoneCountIssues = (event) => {
738
+ const { mode, steps, checkpoints } = event;
739
+ const issues = [];
740
+ if ((steps !== void 0 || checkpoints !== void 0) && mode !== "step_by_step") issues.push(`steps/checkpoints: only sent when mode is step_by_step (got mode ${mode ?? "absent"})`);
741
+ if (checkpoints !== void 0 && steps === void 0) issues.push("checkpoints: sent without steps — pass both");
742
+ if (steps !== void 0 && checkpoints !== void 0 && checkpoints < steps) issues.push(`checkpoints: ${checkpoints} is fewer than steps ${steps} — every step resolves at least one checkpoint`);
743
+ return issues;
744
+ };
745
+ //#endregion
442
746
  //#region src/events/emit.ts
443
747
  const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
444
748
  "session_active_h",
@@ -450,6 +754,7 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
450
754
  "discovered_h",
451
755
  "step",
452
756
  "steps",
757
+ "checkpoints",
453
758
  "review_iterations"
454
759
  ]);
455
760
  const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
@@ -480,7 +785,7 @@ const parseFlags = (args) => {
480
785
  }
481
786
  return result;
482
787
  };
483
- const runEmit = async (options) => {
788
+ const prepareEmit = async (options) => {
484
789
  const { repoRoot, harnessVersion, args } = options;
485
790
  const [rawType, ...rest] = args;
486
791
  if (!rawType) throw new Error(`Usage: lemony emit <type> [--field=value …]. Types: ${EVENT_TYPES.join(", ")}.`);
@@ -507,11 +812,217 @@ const runEmit = async (options) => {
507
812
  const issues = parsed.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
508
813
  throw new Error(`Event validation failed for type "${rawType}":\n${issues}`);
509
814
  }
510
- const eventsPath = join(repoRoot, EVENTS_RELPATH);
511
- await appendEvent(eventsPath, parsed.data);
815
+ const countIssues = parsed.data.type === "task_done" ? taskDoneCountIssues(parsed.data) : [];
816
+ if (countIssues.length > 0) throw new Error(`Event validation failed for type "${rawType}":\n${countIssues.map((issue) => ` - ${issue}`).join("\n")}`);
512
817
  return {
513
818
  event: parsed.data,
514
- eventsPath
819
+ eventsPath: join(repoRoot, EVENTS_RELPATH)
820
+ };
821
+ };
822
+ const runEmit = async (options) => {
823
+ const prepared = await prepareEmit(options);
824
+ await appendEvent(prepared.eventsPath, prepared.event);
825
+ return prepared;
826
+ };
827
+ //#endregion
828
+ //#region src/checkpoint/checkpoint.constant.ts
829
+ const CHECKPOINT_TRAILER = "Lemony-Checkpoint";
830
+ const CHECKPOINT_LAUNCHER = ".claude/hooks/lib/lemony.sh";
831
+ const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
832
+ const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
833
+ //#endregion
834
+ //#region src/checkpoint/checkpoint.model.ts
835
+ const CHECKPOINT_AUTO_COMMIT = ["on", "off"];
836
+ //#endregion
837
+ //#region src/checkpoint/checkpoint.ts
838
+ const USAGE = "lemony checkpoint <ok|changes|ok_downgrade> --task-id=<id> --auto-commit=<on|off> [--step=<N> --review-iterations=<K>]";
839
+ const isAnswer = (value) => CHECKPOINT_RESULTS.includes(value);
840
+ const isAutoCommit = (value) => CHECKPOINT_AUTO_COMMIT.includes(value);
841
+ const parseCall = (inputs) => {
842
+ const fail = (message) => {
843
+ throw new Error(`checkpoint: ${message}\nUsage: ${USAGE}`);
844
+ };
845
+ const { answer, taskId, autoCommit, step, reviewIterations } = inputs;
846
+ if (answer === void 0 || !isAnswer(answer)) return fail(`the answer must be one of ${CHECKPOINT_RESULTS.join(", ")}${answer === void 0 ? "." : `, got "${answer}".`}`);
847
+ if (taskId === void 0) return fail("--task-id is required.");
848
+ if (!TASK_ID_PATTERN.test(taskId)) return fail(`--task-id "${taskId}" is not a task id.`);
849
+ if (autoCommit === void 0 || !isAutoCommit(autoCommit)) return fail(`--auto-commit must be on or off${autoCommit === void 0 ? " (the value progress.md records)." : `, got "${autoCommit}".`}`);
850
+ if (step === void 0) {
851
+ if (answer === "ok_downgrade") return fail("ok_downgrade needs --step: the downgrade is offered only at a step-by-step checkpoint.");
852
+ if (autoCommit === "on") return fail("without --step this is the all-at-once gate, which only auto-commit OFF has.");
853
+ const stepOnly = [
854
+ ["review-iterations", reviewIterations],
855
+ ["attributed-kind", inputs.attributedKind],
856
+ ["attributed-name", inputs.attributedName]
857
+ ].find(([, value]) => value !== void 0);
858
+ if (stepOnly) return fail(`--${stepOnly[0]} goes with --step: the all-at-once gate emits no step_completed.`);
859
+ return {
860
+ answer,
861
+ taskId,
862
+ autoCommit,
863
+ step: void 0
864
+ };
865
+ }
866
+ if (!POSITIVE_INTEGER_PATTERN.test(step)) return fail(`--step must be a positive integer, got "${step}".`);
867
+ if (reviewIterations === void 0) return fail("--review-iterations is required with --step.");
868
+ if (!POSITIVE_INTEGER_PATTERN.test(reviewIterations)) return fail(`--review-iterations must be a positive integer, got "${reviewIterations}".`);
869
+ return {
870
+ answer,
871
+ taskId,
872
+ autoCommit,
873
+ step: {
874
+ number: step,
875
+ reviewIterations
876
+ }
877
+ };
878
+ };
879
+ const buildCheckpointCall = (call) => [
880
+ CHECKPOINT_LAUNCHER,
881
+ "checkpoint",
882
+ call.answer,
883
+ `--task-id=${call.taskId}`,
884
+ `--auto-commit=${call.autoCommit}`,
885
+ ...call.step ? [`--step=${call.step.number}`, `--review-iterations=${call.step.reviewIterations}`] : []
886
+ ].join(" ");
887
+ const commitSubjects = (call) => call.step ? {
888
+ code: `step(${call.taskId}): step ${call.step.number}`,
889
+ state: `step(${call.taskId}): step ${call.step.number} checkpoint OK`
890
+ } : {
891
+ code: `task(${call.taskId}): implementation`,
892
+ state: `task(${call.taskId}): implementation checkpoint OK`
893
+ };
894
+ const firstLine = (text) => text.trim().split("\n")[0] ?? "";
895
+ const bindGit = (run, top) => (...args) => run("git", [
896
+ "--no-literal-pathspecs",
897
+ "-C",
898
+ top,
899
+ ...args
900
+ ]);
901
+ const resolveTopLevel = async (run, repoRoot) => {
902
+ const result = await run("git", [
903
+ "-C",
904
+ repoRoot,
905
+ "rev-parse",
906
+ "--show-toplevel"
907
+ ]);
908
+ if (result.code !== 0) throw new Error(`checkpoint: not inside a git work tree (${firstLine(result.stderr)}).`);
909
+ return result.stdout.trim();
910
+ };
911
+ const IN_PROGRESS = [
912
+ "MERGE_HEAD",
913
+ "CHERRY_PICK_HEAD",
914
+ "REVERT_HEAD",
915
+ "rebase-merge",
916
+ "rebase-apply"
917
+ ];
918
+ const checkRepoState = async (git, top, call) => {
919
+ const taskState = join(TASKS_DIR, call.taskId);
920
+ if (!await pathExists(join(top, taskState))) throw new Error(`checkpoint: no task state at ${taskState} — check --task-id.`);
921
+ if ((await git("symbolic-ref", "--quiet", "HEAD")).code !== 0) throw new Error("checkpoint: HEAD is detached — check out the task branch first.");
922
+ for (const entry of IN_PROGRESS) {
923
+ const path = (await git("rev-parse", "--git-path", entry)).stdout.trim();
924
+ if (path && await pathExists(resolve(top, path))) throw new Error(`checkpoint: ${entry} is present — finish or abort that merge, rebase, cherry-pick or revert first.`);
925
+ }
926
+ if ((await git("ls-files", "--unmerged")).stdout.trim() !== "") throw new Error("checkpoint: the index has unmerged paths — resolve them first.");
927
+ if (call.answer === "changes") return;
928
+ const changed = await git("status", "--porcelain", "--untracked-files=all", "--", join(taskState, "progress.md"));
929
+ if (changed.code !== 0) throw new Error(`checkpoint: git status failed (${firstLine(changed.stderr)}).`);
930
+ if (changed.stdout.trim() !== "") return;
931
+ const ownCall = (await git("log", "-1", "--format=%B")).stdout.split("\n").some((line) => line.trim() === `${CHECKPOINT_TRAILER}: ${buildCheckpointCall(call)}`);
932
+ const touchesState = (await git("diff-tree", "--quiet", "--root", "--no-commit-id", "-r", "HEAD", "--", taskState)).code === 1;
933
+ if (ownCall && touchesState) return;
934
+ throw new Error(`checkpoint: nothing changed in ${join(taskState, "progress.md")} — write the resolved progress.md line first (the state commit carries it), then run this call. If this answer's commits were already made without it, emit step_completed by hand instead.`);
935
+ };
936
+ const guardedCommit = async (git, subject, trailer, pathspec) => {
937
+ const staged = await git("diff", "--cached", "--quiet", "--", ...pathspec);
938
+ if (staged.code === 0) return "empty";
939
+ if (staged.code !== 1) return {
940
+ failedStep: `commit "${subject}"`,
941
+ reason: firstLine(staged.stderr) || `git diff exited ${staged.code}`
942
+ };
943
+ const commit = await git("commit", "-m", subject, "-m", `${CHECKPOINT_TRAILER}: ${trailer}`, "--", ...pathspec);
944
+ if (commit.code !== 0) return {
945
+ failedStep: `commit "${subject}"`,
946
+ reason: commit.stderr.trim() || commit.stdout.trim() || `git commit exited ${commit.code}`
947
+ };
948
+ return { oid: (await git("rev-parse", "--short", "HEAD")).stdout.trim() };
949
+ };
950
+ const isFailure = (value) => typeof value === "object" && value !== null && "failedStep" in value;
951
+ const runCheckpoint = async (inputs) => {
952
+ const call = parseCall(inputs);
953
+ const top = await resolveTopLevel(inputs.runCommand, inputs.repoRoot);
954
+ const git = bindGit(inputs.runCommand, top);
955
+ await checkRepoState(git, top, call);
956
+ const prepared = call.step ? await prepareEmit({
957
+ repoRoot: top,
958
+ harnessVersion: inputs.harnessVersion,
959
+ args: [
960
+ "step_completed",
961
+ `--task-id=${call.taskId}`,
962
+ `--step=${call.step.number}`,
963
+ `--review-iterations=${call.step.reviewIterations}`,
964
+ `--checkpoint-result=${call.answer}`,
965
+ ...inputs.attributedKind === void 0 ? [] : [`--attributed-kind=${inputs.attributedKind}`],
966
+ ...inputs.attributedName === void 0 ? [] : [`--attributed-name=${inputs.attributedName}`]
967
+ ]
968
+ }) : void 0;
969
+ const done = [];
970
+ const warnings = [];
971
+ const stop = (failure) => ({
972
+ ok: false,
973
+ done,
974
+ warnings,
975
+ ...failure
976
+ });
977
+ if (call.autoCommit === "off") {
978
+ const add = await git("add", "-A");
979
+ if (add.code !== 0) return stop({
980
+ failedStep: "git add -A",
981
+ reason: firstLine(add.stderr) || `git add exited ${add.code}`
982
+ });
983
+ done.push(call.answer === "changes" ? "staged the worktree (the human delta)" : "staged the worktree");
984
+ }
985
+ if (call.answer !== "changes") {
986
+ const trailer = buildCheckpointCall(call);
987
+ const subjects = commitSubjects(call);
988
+ const taskState = join(TASKS_DIR, call.taskId);
989
+ if (call.autoCommit === "off") {
990
+ const code = await guardedCommit(git, subjects.code, trailer, [".", `:(exclude)${STATE_DIR}`]);
991
+ if (isFailure(code)) return stop(code);
992
+ done.push(code === "empty" ? "no code change to commit" : `committed ${code.oid} ${subjects.code}`);
993
+ } else {
994
+ const add = await git("add", "--", taskState);
995
+ if (add.code !== 0) return stop({
996
+ failedStep: `git add ${taskState}`,
997
+ reason: firstLine(add.stderr) || `git add exited ${add.code}`
998
+ });
999
+ }
1000
+ const state = await guardedCommit(git, subjects.state, trailer, [taskState]);
1001
+ if (isFailure(state)) return stop(state);
1002
+ done.push(state === "empty" ? `"${subjects.state}" already on the branch` : `committed ${state.oid} ${subjects.state}`);
1003
+ if (call.autoCommit === "off") {
1004
+ const paths = (await git("diff", "--cached", "--name-only", "--", STATE_DIR)).stdout.trim();
1005
+ if (paths !== "") warnings.push(`left staged, not committed (task state outside ${taskState}): ${paths.split("\n").join(", ")}.`);
1006
+ }
1007
+ const push = await git("push");
1008
+ if (push.code === 0) done.push("pushed");
1009
+ else warnings.push(`push failed (${firstLine(push.stderr) || `exit ${push.code}`}); the next push carries it.`);
1010
+ }
1011
+ if (prepared) {
1012
+ try {
1013
+ await appendEvent(prepared.eventsPath, prepared.event);
1014
+ } catch (error) {
1015
+ return stop({
1016
+ failedStep: "emit step_completed",
1017
+ reason: error instanceof Error ? error.message : String(error)
1018
+ });
1019
+ }
1020
+ done.push(`emitted step_completed → ${EVENTS_RELPATH}`);
1021
+ }
1022
+ return {
1023
+ ok: true,
1024
+ done,
1025
+ warnings
515
1026
  };
516
1027
  };
517
1028
  //#endregion
@@ -995,72 +1506,17 @@ const failureReason$1 = (result) => {
995
1506
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
996
1507
  };
997
1508
  //#endregion
998
- //#region src/fs/exists.ts
999
- const pathExists = async (path) => {
1509
+ //#region src/design-tokens/read-token-file.ts
1510
+ const readOptionalTokenFile = async (repoRoot, tokenFile) => {
1511
+ const tokenPath = join(repoRoot, tokenFile);
1000
1512
  try {
1001
- await stat(path);
1002
- return true;
1003
- } catch {
1004
- return false;
1005
- }
1006
- };
1007
- //#endregion
1008
- //#region src/fs/list-files.ts
1009
- const listFiles = async (dir) => {
1010
- const entries = await readdir(dir, { withFileTypes: true });
1011
- return (await Promise.all(entries.map(async (entry) => {
1012
- if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
1013
- return [entry.name];
1014
- }))).flat();
1015
- };
1016
- //#endregion
1017
- //#region src/fs/prune-empty-dirs.ts
1018
- const pruneEmptyDirs = async (repoRoot, removedFiles) => {
1019
- const dirs = /* @__PURE__ */ new Set();
1020
- for (const relPath of removedFiles) {
1021
- let dir = dirname(relPath);
1022
- while (dir !== "." && dir !== sep) {
1023
- dirs.add(dir);
1024
- dir = dirname(dir);
1025
- }
1026
- }
1027
- const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
1028
- for (const dir of ordered) try {
1029
- await rmdir(join(repoRoot, dir));
1030
- } catch {}
1031
- };
1032
- //#endregion
1033
- //#region src/fs/write-managed.ts
1034
- const writeManaged = async (root, relPath, content, executable) => {
1035
- const dest = join(root, relPath);
1036
- await mkdir(dirname(dest), { recursive: true });
1037
- await writeFile(dest, content);
1038
- await chmod(dest, executable ? 493 : 420);
1039
- };
1040
- //#endregion
1041
- //#region src/fs/symlink-guard.ts
1042
- const assertNoSymlinkTraversal = async (root, relPaths) => {
1043
- const components = /* @__PURE__ */ new Set();
1044
- for (const relPath of relPaths) {
1045
- let current = "";
1046
- for (const part of relPath.split(sep)) {
1047
- if (part.length === 0) continue;
1048
- current = current ? join(current, part) : part;
1049
- components.add(current);
1050
- }
1513
+ if (!await entryExists(tokenPath)) return void 0;
1514
+ return await readRegularFile(tokenPath);
1515
+ } catch (error) {
1516
+ if (error.code === "ENOENT") throw new Error(`Cannot read ${tokenFile} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
1517
+ throw new Error(`Cannot read ${tokenFile} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1051
1518
  }
1052
- const offenders = (await Promise.all([...components].map(async (rel) => {
1053
- try {
1054
- return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
1055
- } catch {
1056
- return null;
1057
- }
1058
- }))).filter((rel) => rel !== null);
1059
- if (offenders.length > 0) throw new Error("Refusing to write — a managed path traverses a symlink (a redirect could land outside the repo):\n" + offenders.map((rel) => ` ${rel}`).join("\n"));
1060
1519
  };
1061
- //#endregion
1062
- //#region src/design-tokens/design-tokens.constant.ts
1063
- const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
1064
1520
  const SCANNED_EXTENSIONS = [
1065
1521
  ".css",
1066
1522
  ".scss",
@@ -1109,6 +1565,47 @@ const TOKEN_TIERS = [
1109
1565
  "component"
1110
1566
  ];
1111
1567
  //#endregion
1568
+ //#region src/design-tokens/contrast.constant.ts
1569
+ const CONTRAST_EXTENSION = "com.lemony.contrast";
1570
+ const MODES_EXTENSION = "com.lemony.modes";
1571
+ const BASE_MODE = "base";
1572
+ const DEFAULT_LEVEL = "text";
1573
+ const WCAG_FLOORS = {
1574
+ text: 4.5,
1575
+ large: 3,
1576
+ "non-text": 3
1577
+ };
1578
+ //#endregion
1579
+ //#region src/design-tokens/nesting-depth.ts
1580
+ const nestedTooDeeply = (tokenFile) => `${tokenFile} nests deeper than 64 levels, the most Lemony reads.`;
1581
+ const exceedsNestingLimit = (value, limit = 64) => {
1582
+ const pending = [{
1583
+ node: value,
1584
+ depth: 1
1585
+ }];
1586
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
1587
+ const { node, depth } = next;
1588
+ if (typeof node !== "object" || node === null) continue;
1589
+ if (depth > limit) return true;
1590
+ for (const child of Object.values(node)) pending.push({
1591
+ node: child,
1592
+ depth: depth + 1
1593
+ });
1594
+ }
1595
+ return false;
1596
+ };
1597
+ //#endregion
1598
+ //#region src/design-tokens/non-finite-number.ts
1599
+ const nonFiniteNumberFaults = (value, tokenFile) => nonFiniteNumbers(value, []).map(({ path, number }) => `${tokenFile} has a number at "${path}" too large for JSON to carry: it reads as ${number}, and rewriting the file would turn it into null.`);
1600
+ const nonFiniteNumbers = (value, path) => {
1601
+ if (typeof value === "number") return Number.isFinite(value) ? [] : [{
1602
+ path: path.join("."),
1603
+ number: value
1604
+ }];
1605
+ if (typeof value !== "object" || value === null) return [];
1606
+ return Object.entries(value).flatMap(([key, child]) => nonFiniteNumbers(child, [...path, key]));
1607
+ };
1608
+ //#endregion
1112
1609
  //#region src/design-tokens/token-parse.ts
1113
1610
  const aliasTarget = (value) => {
1114
1611
  if (typeof value !== "string") return void 0;
@@ -1117,18 +1614,35 @@ const aliasTarget = (value) => {
1117
1614
  };
1118
1615
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1119
1616
  //#endregion
1617
+ //#region src/design-tokens/resolve-token-file.ts
1618
+ const resolveTokenFile = (declared) => declared ?? "docs/design-tokens.json";
1619
+ //#endregion
1120
1620
  //#region src/design-tokens/design-tokens.ts
1121
1621
  const runValidate = async (inputs) => {
1122
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1123
- if (!await pathExists(tokenPath)) return {
1622
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
1623
+ let raw;
1624
+ try {
1625
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
1626
+ } catch (error) {
1627
+ return {
1628
+ ok: false,
1629
+ tokensFound: true,
1630
+ tokenCount: 0,
1631
+ filesScanned: 0,
1632
+ violations: [{
1633
+ kind: "unreadable-token-file",
1634
+ message: error instanceof Error ? error.message : String(error)
1635
+ }]
1636
+ };
1637
+ }
1638
+ if (raw === void 0) return {
1124
1639
  ok: true,
1125
1640
  tokensFound: false,
1126
1641
  tokenCount: 0,
1127
1642
  filesScanned: 0,
1128
1643
  violations: []
1129
1644
  };
1130
- const raw = await readFile(tokenPath, "utf8");
1131
- const fileCheck = validateTokenFile(raw);
1645
+ const fileCheck = validateTokenFile(raw, tokenFile);
1132
1646
  if (!fileCheck.ok) return {
1133
1647
  ok: false,
1134
1648
  tokensFound: true,
@@ -1137,8 +1651,21 @@ const runValidate = async (inputs) => {
1137
1651
  violations: fileCheck.violations
1138
1652
  };
1139
1653
  const scanRoot = inputs.scanDir ? join(inputs.repoRoot, inputs.scanDir) : inputs.repoRoot;
1654
+ if (inputs.scanDir) {
1655
+ const unreadable = await describeUnreadableDir(scanRoot);
1656
+ if (unreadable !== void 0) return {
1657
+ ok: false,
1658
+ tokensFound: true,
1659
+ tokenCount: fileCheck.tokenCount,
1660
+ filesScanned: 0,
1661
+ violations: [{
1662
+ kind: "unreadable-scan-root",
1663
+ message: `--scan=${inputs.scanDir} ${unreadable} — nothing was scanned.`
1664
+ }]
1665
+ };
1666
+ }
1140
1667
  const extensions = mergeExtensions(inputs.extraExtensions);
1141
- const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1668
+ const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions, tokenFile);
1142
1669
  return {
1143
1670
  ok: sourceViolations.violations.length === 0,
1144
1671
  tokensFound: true,
@@ -1147,7 +1674,15 @@ const runValidate = async (inputs) => {
1147
1674
  violations: sourceViolations.violations
1148
1675
  };
1149
1676
  };
1150
- const validateTokenFile = (raw) => {
1677
+ const summarizeTokenFileViolations = (raw, tokenFile) => summarizeMessages(tokenFileViolationMessages(raw, tokenFile));
1678
+ const tokenFileViolations = (raw, tokenFile) => validateTokenFile(raw, tokenFile).violations;
1679
+ const tokenFileViolationMessages = (raw, tokenFile) => tokenFileViolations(raw, tokenFile).map((violation) => violation.message);
1680
+ const summarizeMessages = (messages) => {
1681
+ const [first, ...more] = messages;
1682
+ if (first === void 0) return void 0;
1683
+ return `${first}${more.length > 0 ? ` (and ${more.length} more)` : ""}`;
1684
+ };
1685
+ const validateTokenFile = (raw, tokenFile) => {
1151
1686
  let parsed;
1152
1687
  try {
1153
1688
  parsed = JSON.parse(raw);
@@ -1157,7 +1692,7 @@ const validateTokenFile = (raw) => {
1157
1692
  tokenCount: 0,
1158
1693
  violations: [{
1159
1694
  kind: "invalid-json",
1160
- message: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
1695
+ message: `${tokenFile} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
1161
1696
  }]
1162
1697
  };
1163
1698
  }
@@ -1166,10 +1701,22 @@ const validateTokenFile = (raw) => {
1166
1701
  tokenCount: 0,
1167
1702
  violations: [{
1168
1703
  kind: "malformed-token-file",
1169
- message: `${DESIGN_TOKENS_FILE} must be a JSON object with primitive/semantic/component tiers.`
1704
+ message: `${tokenFile} must be a JSON object with primitive/semantic/component tiers.`
1705
+ }]
1706
+ };
1707
+ if (exceedsNestingLimit(parsed)) return {
1708
+ ok: false,
1709
+ tokenCount: 0,
1710
+ violations: [{
1711
+ kind: "malformed-token-file",
1712
+ message: nestedTooDeeply(tokenFile)
1170
1713
  }]
1171
1714
  };
1172
1715
  const violations = [];
1716
+ for (const message of nonFiniteNumberFaults(parsed, tokenFile)) violations.push({
1717
+ kind: "malformed-token-file",
1718
+ message
1719
+ });
1173
1720
  for (const key of Object.keys(parsed)) {
1174
1721
  if (key.startsWith("$")) continue;
1175
1722
  if (!TOKEN_TIERS.includes(key)) violations.push({
@@ -1197,6 +1744,10 @@ const validateTokenFile = (raw) => {
1197
1744
  violations
1198
1745
  };
1199
1746
  };
1747
+ const modeValues = (extensions) => {
1748
+ const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
1749
+ return isRecord(modes) ? modes : {};
1750
+ };
1200
1751
  const collectTokens$1 = (node, tier, path, out, violations) => {
1201
1752
  for (const [key, child] of Object.entries(node)) {
1202
1753
  if (key.startsWith("$")) continue;
@@ -1211,7 +1762,8 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1211
1762
  if ("$value" in child) {
1212
1763
  out.set(childPath, {
1213
1764
  tier,
1214
- value: child.$value
1765
+ value: child.$value,
1766
+ modes: modeValues(child["$extensions"])
1215
1767
  });
1216
1768
  const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
1217
1769
  if (stray.length > 0) violations.push({
@@ -1223,41 +1775,162 @@ const collectTokens$1 = (node, tier, path, out, violations) => {
1223
1775
  };
1224
1776
  const collectAliasViolations = (tokens) => {
1225
1777
  const violations = [];
1778
+ for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
1779
+ const alias = aliasTarget(value);
1780
+ if (alias !== void 0 && token.tier === "primitive") {
1781
+ violations.push({
1782
+ kind: "primitive-not-literal",
1783
+ message: `primitive token "${path}" must hold a literal value in mode "${mode}", not the alias "{${alias}}".`
1784
+ });
1785
+ continue;
1786
+ }
1787
+ if (alias !== void 0 && !tokens.has(alias)) violations.push({
1788
+ kind: "unresolved-alias",
1789
+ message: `token "${path}" references "{${alias}}" in mode "${mode}", which does not exist.`
1790
+ });
1791
+ }
1792
+ for (const [path, token] of tokens) {
1793
+ const values = [[void 0, token.value], ...Object.entries(token.modes)];
1794
+ for (const [mode, value] of values) {
1795
+ const where = mode === void 0 ? "" : ` in mode "${mode}"`;
1796
+ for (const { at, alias } of compositeAliases(value)) if (token.tier === "primitive") violations.push({
1797
+ kind: "primitive-not-literal",
1798
+ message: `primitive token "${path}" must hold a literal value${where}, not the alias "{${alias}}" at "${at}".`
1799
+ });
1800
+ else if (!tokens.has(alias)) violations.push({
1801
+ kind: "unresolved-alias",
1802
+ message: `token "${path}" references "{${alias}}" at "${at}" in its value${where}, which does not exist.`
1803
+ });
1804
+ }
1805
+ }
1806
+ const loopsInBase = tokensReachingACycle(tokens, [...tokens.keys()]);
1226
1807
  for (const [path, token] of tokens) {
1227
1808
  const alias = aliasTarget(token.value);
1228
- if (alias === void 0) continue;
1229
- if (token.tier === "primitive") {
1809
+ if (alias !== void 0 && token.tier === "primitive") {
1230
1810
  violations.push({
1231
1811
  kind: "primitive-not-literal",
1232
1812
  message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
1233
1813
  });
1234
1814
  continue;
1235
1815
  }
1236
- if (!tokens.has(alias)) {
1816
+ if (alias !== void 0 && !tokens.has(alias)) {
1237
1817
  violations.push({
1238
1818
  kind: "unresolved-alias",
1239
1819
  message: `token "${path}" references "{${alias}}", which does not exist.`
1240
1820
  });
1241
1821
  continue;
1242
1822
  }
1243
- if (aliasChainCycles(path, tokens)) violations.push({
1823
+ if (token.tier === "primitive") continue;
1824
+ if (loopsInBase.has(path)) violations.push({
1244
1825
  kind: "cyclic-alias",
1245
1826
  message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1246
1827
  });
1247
1828
  }
1829
+ const owners = /* @__PURE__ */ new Map();
1830
+ for (const [path, token] of tokens) for (const mode of Object.keys(token.modes)) {
1831
+ const owning = owners.get(mode);
1832
+ if (owning === void 0) owners.set(mode, [path]);
1833
+ else owning.push(path);
1834
+ }
1835
+ const baseReferrers = /* @__PURE__ */ new Map();
1836
+ for (const [path, token] of tokens) {
1837
+ if (loopsInBase.has(path)) continue;
1838
+ for (const target of aliasReferences(token.value)) {
1839
+ const referring = baseReferrers.get(target);
1840
+ if (referring === void 0) baseReferrers.set(target, [path]);
1841
+ else referring.push(path);
1842
+ }
1843
+ }
1844
+ const loopsInMode = /* @__PURE__ */ new Map();
1845
+ const loopsWithoutOverride = /* @__PURE__ */ new Map();
1846
+ for (const [mode, owning] of owners) {
1847
+ const loops = tokensReachingACycle(tokens, owning, mode);
1848
+ loopsInMode.set(mode, loops);
1849
+ const feeding = tokensFeedingInto(tokens, baseReferrers, loops, mode);
1850
+ for (const path of [...loops, ...feeding]) {
1851
+ const token = tokens.get(path);
1852
+ if (token === void 0 || Object.hasOwn(token.modes, mode)) continue;
1853
+ if (loopsInBase.has(path)) continue;
1854
+ const looping = loopsWithoutOverride.get(path);
1855
+ if (looping === void 0) loopsWithoutOverride.set(path, [mode]);
1856
+ else looping.push(mode);
1857
+ }
1858
+ }
1859
+ for (const [path, token] of tokens) {
1860
+ if (token.tier === "primitive") continue;
1861
+ const looping = [...Object.keys(token.modes).filter((mode) => loopsInMode.get(mode)?.has(path)), ...loopsWithoutOverride.get(path) ?? []];
1862
+ for (const mode of looping) violations.push({
1863
+ kind: "cyclic-alias",
1864
+ message: `token "${path}" has an alias chain in mode "${mode}" that never resolves to a literal value (alias cycle).`
1865
+ });
1866
+ }
1248
1867
  return violations;
1249
1868
  };
1250
- const aliasChainCycles = (start, tokens) => {
1251
- const seen = /* @__PURE__ */ new Set();
1252
- let current = start;
1253
- while (current !== void 0) {
1254
- if (seen.has(current)) return true;
1255
- seen.add(current);
1256
- const token = tokens.get(current);
1257
- if (token === void 0) return false;
1258
- current = aliasTarget(token.value);
1869
+ const tokensFeedingInto = (tokens, baseReferrers, loops, mode) => {
1870
+ const found = /* @__PURE__ */ new Set();
1871
+ const queue = [...loops];
1872
+ for (let path = queue.pop(); path !== void 0; path = queue.pop()) for (const referrer of baseReferrers.get(path) ?? []) {
1873
+ if (Object.hasOwn(tokens.get(referrer)?.modes ?? {}, mode)) continue;
1874
+ if (loops.has(referrer) || found.has(referrer)) continue;
1875
+ found.add(referrer);
1876
+ queue.push(referrer);
1259
1877
  }
1260
- return false;
1878
+ return found;
1879
+ };
1880
+ const compositeAliases = (value, at = []) => {
1881
+ if (typeof value !== "object" || value === null) {
1882
+ const alias = at.length === 0 ? void 0 : aliasTarget(value);
1883
+ return alias === void 0 ? [] : [{
1884
+ at: at.join("."),
1885
+ alias
1886
+ }];
1887
+ }
1888
+ return Object.entries(value).flatMap(([key, child]) => compositeAliases(child, [...at, key]));
1889
+ };
1890
+ const aliasReferences = (value) => {
1891
+ const alias = aliasTarget(value);
1892
+ return alias === void 0 ? compositeAliases(value).map((leaf) => leaf.alias) : [alias];
1893
+ };
1894
+ const tokensReachingACycle = (tokens, starts, mode) => {
1895
+ const references = (path) => {
1896
+ const token = tokens.get(path);
1897
+ if (token === void 0) return [];
1898
+ return aliasReferences(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : token.value);
1899
+ };
1900
+ const onPath = /* @__PURE__ */ new Set();
1901
+ const settled = /* @__PURE__ */ new Map();
1902
+ for (const start of starts) {
1903
+ if (settled.has(start)) continue;
1904
+ const stack = [{
1905
+ path: start,
1906
+ next: references(start),
1907
+ loops: false
1908
+ }];
1909
+ onPath.add(start);
1910
+ for (let frame = stack.at(-1); frame !== void 0; frame = stack.at(-1)) {
1911
+ const target = frame.next.pop();
1912
+ if (target === void 0) {
1913
+ stack.pop();
1914
+ onPath.delete(frame.path);
1915
+ settled.set(frame.path, frame.loops);
1916
+ const parent = stack.at(-1);
1917
+ if (parent !== void 0 && frame.loops) parent.loops = true;
1918
+ continue;
1919
+ }
1920
+ if (onPath.has(target)) frame.loops = true;
1921
+ else if (settled.has(target)) {
1922
+ if (settled.get(target)) frame.loops = true;
1923
+ } else {
1924
+ stack.push({
1925
+ path: target,
1926
+ next: references(target),
1927
+ loops: false
1928
+ });
1929
+ onPath.add(target);
1930
+ }
1931
+ }
1932
+ }
1933
+ return new Set([...settled].filter(([, loops]) => loops).map(([path]) => path));
1261
1934
  };
1262
1935
  const mergeExtensions = (extra) => {
1263
1936
  const set = new Set(SCANNED_EXTENSIONS);
@@ -1268,33 +1941,64 @@ const mergeExtensions = (extra) => {
1268
1941
  }
1269
1942
  return set;
1270
1943
  };
1271
- const scanSource = async (repoRoot, scanRoot, extensions) => {
1944
+ const codeOf = (error) => error.code ?? String(error);
1945
+ const describeUnreadableDir = async (dir) => {
1946
+ try {
1947
+ await readdir(dir);
1948
+ } catch (error) {
1949
+ const code = codeOf(error);
1950
+ if (code === "ENOENT") return "does not exist";
1951
+ if (code === "ENOTDIR") return "is not a directory";
1952
+ return `cannot be listed (${code})`;
1953
+ }
1954
+ try {
1955
+ await access(dir, constants.R_OK | constants.X_OK);
1956
+ return;
1957
+ } catch (error) {
1958
+ return `can be listed but not searched (${codeOf(error)})`;
1959
+ }
1960
+ };
1961
+ const scanSource = async (repoRoot, scanRoot, extensions, tokenFile) => {
1272
1962
  if (!await pathExists(scanRoot)) return {
1273
1963
  filesScanned: 0,
1274
1964
  violations: []
1275
1965
  };
1276
- const files = (await collectSourceFiles(scanRoot, extensions)).toSorted();
1277
- const violations = [];
1966
+ const unlistable = [];
1967
+ const files = (await collectSourceFiles(scanRoot, extensions, unlistable)).toSorted();
1968
+ const violations = unlistable.map(({ dir, code }) => ({
1969
+ kind: "unreadable-source-dir",
1970
+ message: `directory cannot be listed (${code}) — nothing under it was scanned.`,
1971
+ file: relative(repoRoot, dir).split(sep).join("/") || "."
1972
+ })).toSorted((a, b) => a.file.localeCompare(b.file));
1278
1973
  let filesScanned = 0;
1279
1974
  for (const file of files) {
1280
- const content = await readFile(file, "utf8").catch(() => void 0);
1975
+ const content = await readRegularFile(file).catch(() => void 0);
1281
1976
  if (content === void 0) continue;
1282
1977
  filesScanned += 1;
1283
1978
  const relPath = relative(repoRoot, file).split(sep).join("/");
1284
- violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
1979
+ violations.push(...scanFile(relPath, extname(file).toLowerCase(), content, tokenFile));
1285
1980
  }
1286
1981
  return {
1287
1982
  filesScanned,
1288
1983
  violations
1289
1984
  };
1290
1985
  };
1291
- const collectSourceFiles = async (dir, extensions) => {
1292
- const entries = await readdir(dir, { withFileTypes: true });
1986
+ const collectSourceFiles = async (dir, extensions, unlistable) => {
1987
+ let entries;
1988
+ try {
1989
+ entries = await readdir(dir, { withFileTypes: true });
1990
+ } catch (error) {
1991
+ unlistable.push({
1992
+ dir,
1993
+ code: codeOf(error)
1994
+ });
1995
+ return [];
1996
+ }
1293
1997
  return (await Promise.all(entries.map(async (entry) => {
1294
1998
  const full = join(dir, entry.name);
1295
1999
  if (entry.isDirectory()) {
1296
2000
  if (IGNORED_DIRS.has(entry.name)) return [];
1297
- return collectSourceFiles(full, extensions);
2001
+ return collectSourceFiles(full, extensions, unlistable);
1298
2002
  }
1299
2003
  return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
1300
2004
  }))).flat();
@@ -1303,7 +2007,7 @@ const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{
1303
2007
  const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
1304
2008
  const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
1305
2009
  const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
1306
- const scanFile = (relPath, ext, content) => {
2010
+ const scanFile = (relPath, ext, content, tokenFile) => {
1307
2011
  const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
1308
2012
  const violations = [];
1309
2013
  content.split("\n").forEach((line, index) => {
@@ -1311,13 +2015,13 @@ const scanFile = (relPath, ext, content) => {
1311
2015
  const lineNo = index + 1;
1312
2016
  if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
1313
2017
  kind: "hardcoded-color",
1314
- message: `raw color literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
2018
+ message: `raw color literal — reference a token from ${tokenFile} instead.`,
1315
2019
  file: relPath,
1316
2020
  line: lineNo
1317
2021
  });
1318
2022
  if (isStylesheet && hasRawDimension(line)) violations.push({
1319
2023
  kind: "hardcoded-dimension",
1320
- message: `raw dimension literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
2024
+ message: `raw dimension literal — reference a token from ${tokenFile} instead.`,
1321
2025
  file: relPath,
1322
2026
  line: lineNo
1323
2027
  });
@@ -1465,18 +2169,11 @@ const contrastRatio = (foreground, background) => {
1465
2169
  const l2 = relativeLuminance(bg);
1466
2170
  const light = Math.max(l1, l2);
1467
2171
  const dark = Math.min(l1, l2);
1468
- return Math.round((light + .05) / (dark + .05) * 100) / 100;
2172
+ return (light + .05) / (dark + .05);
1469
2173
  };
1470
- //#endregion
1471
- //#region src/design-tokens/contrast.constant.ts
1472
- const CONTRAST_EXTENSION = "com.lemony.contrast";
1473
- const MODES_EXTENSION = "com.lemony.modes";
1474
- const BASE_MODE = "base";
1475
- const DEFAULT_LEVEL = "text";
1476
- const WCAG_FLOORS = {
1477
- text: 4.5,
1478
- large: 3,
1479
- "non-text": 3
2174
+ const truncateRatio = (ratio) => {
2175
+ const hundredths = Math.floor(ratio * 100);
2176
+ return hundredths / 100 > ratio ? (hundredths - 1) / 100 : hundredths / 100;
1480
2177
  };
1481
2178
  //#endregion
1482
2179
  //#region src/design-tokens/contrast.model.ts
@@ -1488,38 +2185,58 @@ const CONTRAST_LEVELS = [
1488
2185
  //#endregion
1489
2186
  //#region src/design-tokens/contrast.ts
1490
2187
  const runContrast = async (inputs) => {
1491
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1492
- if (!await pathExists(tokenPath)) return emptyResult$1(false);
1493
- const raw = await readFile(tokenPath, "utf8");
2188
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
2189
+ let raw;
2190
+ let unreadable;
2191
+ try {
2192
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
2193
+ } catch (error) {
2194
+ unreadable = error instanceof Error ? error.message : String(error);
2195
+ }
2196
+ const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
2197
+ const withVerify = (result) => ({
2198
+ ...result,
2199
+ ok: result.ok && (verify === void 0 || verify.code === 0),
2200
+ ...verify === void 0 ? {} : { verify }
2201
+ });
2202
+ if (unreadable !== void 0) return withVerify({
2203
+ ...emptyResult$1(true),
2204
+ ok: false,
2205
+ problems: [unreadable]
2206
+ });
2207
+ if (raw === void 0) return withVerify(emptyResult$1(false));
1494
2208
  let parsed;
1495
2209
  try {
1496
2210
  parsed = JSON.parse(raw);
1497
2211
  } catch (error) {
1498
- return {
2212
+ return withVerify({
1499
2213
  ...emptyResult$1(true),
1500
2214
  ok: false,
1501
- problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1502
- };
2215
+ problems: [`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
2216
+ });
1503
2217
  }
1504
- if (!isRecord(parsed)) return {
2218
+ if (!isRecord(parsed)) return withVerify({
1505
2219
  ...emptyResult$1(true),
1506
2220
  ok: false,
1507
- problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
1508
- };
2221
+ problems: [`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`]
2222
+ });
2223
+ if (exceedsNestingLimit(parsed)) return withVerify({
2224
+ ...emptyResult$1(true),
2225
+ ok: false,
2226
+ problems: [`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`]
2227
+ });
1509
2228
  const tokens = collectTokens(parsed);
1510
2229
  const problems = [];
1511
2230
  const modes = declaredModes(tokens);
1512
2231
  const specs = discoverPairs(tokens, modes, problems);
1513
2232
  const pairs = measurePairs(specs, modes, tokens);
1514
- const verify = inputs.verify ? await runVerifier(inputs.verify) : void 0;
1515
- return {
1516
- ok: problems.length === 0 && pairs.every((pair) => pair.passes) && (verify === void 0 || verify.code === 0),
2233
+ return withVerify({
2234
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1517
2235
  tokensFound: true,
1518
2236
  pairsChecked: pairs.length,
1519
2237
  pairs,
1520
- problems,
1521
- ...verify === void 0 ? {} : { verify }
1522
- };
2238
+ problems
2239
+ });
1523
2240
  };
1524
2241
  const emptyResult$1 = (tokensFound) => ({
1525
2242
  ok: true,
@@ -1564,12 +2281,12 @@ const declaredModes = (tokens) => {
1564
2281
  };
1565
2282
  const pairKey = (spec) => `${spec.foreground}|${spec.background}`;
1566
2283
  const discoverPairs = (tokens, modes, problems) => {
1567
- const convention = new Map(conventionPairs(tokens).map((spec) => [pairKey(spec), spec]));
2284
+ const convention = new Map(conventionPairs(tokens, modes, problems).map((spec) => [pairKey(spec), spec]));
1568
2285
  const explicit = extensionPairs(tokens, modes, problems);
1569
2286
  for (const spec of explicit) convention.delete(pairKey(spec));
1570
2287
  return [...convention.values(), ...explicit];
1571
2288
  };
1572
- const conventionPairs = (tokens) => {
2289
+ const conventionPairs = (tokens, modes, problems) => {
1573
2290
  const pairs = [];
1574
2291
  for (const [path, token] of tokens) {
1575
2292
  if (token.tier !== "semantic") continue;
@@ -1580,6 +2297,21 @@ const conventionPairs = (tokens) => {
1580
2297
  if (!tokens.has(basePath)) continue;
1581
2298
  if (!resolveColor(path, "base", tokens)) continue;
1582
2299
  if (!resolveColor(basePath, "base", tokens)) continue;
2300
+ const broken = [{
2301
+ end: path,
2302
+ other: basePath
2303
+ }, {
2304
+ end: basePath,
2305
+ other: path
2306
+ }].flatMap(({ end, other }) => modes.flatMap((mode) => {
2307
+ const fault = modeAliasBreak(end, mode, tokens);
2308
+ if (fault === void 0) return [];
2309
+ return fault === "too-deep" ? [`token "${end}" has a "${mode}" mode alias chain of 64 hops or more, which contrast does not follow, so its convention pair with "${other}" is not measured — shorten the chain.`] : [`token "${end}" has a "${mode}" mode alias that does not resolve (a missing token or an alias cycle), so its convention pair with "${other}" is not measured — run \`design-tokens validate\`.`];
2310
+ }));
2311
+ if (broken.length > 0) {
2312
+ problems.push(...broken);
2313
+ continue;
2314
+ }
1583
2315
  pairs.push({
1584
2316
  foreground: path,
1585
2317
  background: basePath,
@@ -1773,7 +2505,7 @@ const makeRow = (spec, mode, foreground, background) => {
1773
2505
  ...spec.over === void 0 ? {} : { over: spec.over },
1774
2506
  mode,
1775
2507
  level: spec.level,
1776
- ratio,
2508
+ ratio: truncateRatio(ratio),
1777
2509
  floor,
1778
2510
  passes: ratio >= floor,
1779
2511
  source: spec.source
@@ -1788,12 +2520,26 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1788
2520
  let value = token.value;
1789
2521
  if (mode !== "base") {
1790
2522
  const modes = readExtension(token.node, MODES_EXTENSION);
1791
- if (isRecord(modes) && modes[mode] !== void 0) value = modes[mode];
2523
+ if (isRecord(modes) && Object.hasOwn(modes, mode)) value = modes[mode];
1792
2524
  }
1793
2525
  const alias = aliasTarget(value);
1794
2526
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1795
2527
  return typeof value === "string" ? parseColor(value) : void 0;
1796
2528
  };
2529
+ const modeAliasBreak = (path, mode, tokens) => {
2530
+ const seen = /* @__PURE__ */ new Set();
2531
+ let current = path;
2532
+ while (current !== void 0) {
2533
+ if (seen.has(current)) return "unresolved";
2534
+ seen.add(current);
2535
+ const token = tokens.get(current);
2536
+ if (!token) return "unresolved";
2537
+ const modes = readExtension(token.node, MODES_EXTENSION);
2538
+ const value = isRecord(modes) && Object.hasOwn(modes, mode) ? modes[mode] : token.value;
2539
+ current = aliasTarget(value);
2540
+ }
2541
+ return seen.size > 64 ? "too-deep" : void 0;
2542
+ };
1797
2543
  const runVerifier = async (hook) => {
1798
2544
  const result = await hook.run("sh", ["-c", hook.command]);
1799
2545
  return {
@@ -1804,32 +2550,105 @@ const runVerifier = async (hook) => {
1804
2550
  //#endregion
1805
2551
  //#region src/design-tokens/design-sync.constant.ts
1806
2552
  const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
2553
+ const NEUTRAL_STRING_VALUES = "The neutral format carries every value as a string (e.g. \"1024\", \"#3b82f6\") — fix the adapter that wrote the file.";
2554
+ const NEUTRAL_MODES_OBJECT = "It maps each mode name to that mode's value (e.g. {\"dark\": \"#000000\"}) — fix the adapter that wrote the file.";
2555
+ const NEUTRAL_VALUE_OR_REF = "Each variable carries a string \"value\" (a literal) or a non-empty string \"ref\" (the name of the variable it aliases) — fix the adapter that wrote the file.";
2556
+ const NEUTRAL_NAME_SHAPE = "A name is dot-separated segments, like \"primitive.color.brand\" — fix the adapter that wrote the file.";
2557
+ const VALIDATE_HINT = "Run `lemony design-tokens validate` for the full list.";
2558
+ const IMPORT_INVALID_HINT = "Change the `--only` slice (add what it references), or fix the file first; nothing was written.";
2559
+ const IMPORT_FAULT_CLAUSE = "Fix the variables named above in the design tool, or leave them out with `--only`";
2560
+ const IMPORT_PREVIEW_INVALID_HINT = "`--apply` refuses any slice that leaves it failing: include what repairs it, or fix the file first.";
2561
+ const IMPORT_TOO_DEEP_HINT = "Shorten the dotted names of the variables that land that deep, or leave them out with `--only`; nothing was written.";
2562
+ //#endregion
2563
+ //#region src/design-tokens/read-required-file.ts
2564
+ const readRequiredFile = async (path, label) => {
2565
+ try {
2566
+ return await readRegularFile(path);
2567
+ } catch (error) {
2568
+ if (error.code === "ENOENT") {
2569
+ if (!await entryExists(path)) throw new Error(`${label} file not found: ${path}`, { cause: error });
2570
+ throw new Error(`Cannot read ${label} file at ${path}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
2571
+ }
2572
+ throw new Error(`Cannot read ${label} file at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2573
+ }
2574
+ };
1807
2575
  //#endregion
1808
2576
  //#region src/design-tokens/neutral-mapping.ts
1809
2577
  const KNOWN_TIERS = new Set(TOKEN_TIERS);
1810
2578
  const dtcgToNeutral = (parsed) => {
2579
+ const nodes = tokenNodes(parsed);
2580
+ const unsynced = unsyncedReasons(nodes);
1811
2581
  const variables = [];
1812
- for (const tier of TOKEN_TIERS) {
1813
- const group = parsed[tier];
1814
- if (isRecord(group)) walk(group, tier, variables);
1815
- }
2582
+ for (const [path, node] of nodes) if (!unsynced.has(path)) variables.push(toVariable$1(path, node));
1816
2583
  return {
1817
2584
  schemaVersion: 1,
1818
2585
  variables: variables.toSorted(byName)
1819
2586
  };
1820
2587
  };
1821
- const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
2588
+ const unsyncedTokens = (parsed) => [...unsyncedReasons(tokenNodes(parsed))].map(([path, reason]) => ({
2589
+ path,
2590
+ reason
2591
+ })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2592
+ const tokenNodes = (parsed) => {
2593
+ const nodes = /* @__PURE__ */ new Map();
2594
+ for (const tier of TOKEN_TIERS) {
2595
+ const group = parsed[tier];
2596
+ if (isRecord(group)) walk(group, tier, nodes);
2597
+ }
2598
+ return nodes;
2599
+ };
1822
2600
  const walk = (node, path, out) => {
1823
2601
  for (const [key, child] of Object.entries(node)) {
1824
2602
  if (key.startsWith("$") || !isRecord(child)) continue;
1825
2603
  const childPath = `${path}.${key}`;
1826
- if ("$value" in child) out.push(toVariable$1(childPath, child));
2604
+ if ("$value" in child) out.set(childPath, child);
1827
2605
  else walk(child, childPath, out);
1828
2606
  }
1829
2607
  };
2608
+ const unsyncedReasons = (nodes) => {
2609
+ const reasons = /* @__PURE__ */ new Map();
2610
+ for (const [path, node] of nodes) {
2611
+ const reason = ownUnsyncedReason(node);
2612
+ if (reason !== void 0) reasons.set(path, reason);
2613
+ }
2614
+ let grew = true;
2615
+ while (grew) {
2616
+ grew = false;
2617
+ for (const [path, node] of nodes) {
2618
+ if (reasons.has(path)) continue;
2619
+ const target = aliasTargetsOf(node).find((name) => reasons.has(name));
2620
+ if (target === void 0) continue;
2621
+ reasons.set(path, `an alias to ${target}`);
2622
+ grew = true;
2623
+ }
2624
+ }
2625
+ return reasons;
2626
+ };
2627
+ const ownUnsyncedReason = (node) => {
2628
+ if (isCompositeValue(node["$value"])) return "a composite value";
2629
+ const modes = modesExtension(node);
2630
+ if (modes === void 0) return void 0;
2631
+ if (!isRecord(modes)) return "a modes extension that is not an object";
2632
+ for (const [name, value] of Object.entries(modes)) {
2633
+ if (name.trim().length === 0) return "a mode with a blank name";
2634
+ if (name === "__proto__") return "a mode named \"__proto__\"";
2635
+ if (!isScalar$1(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
2636
+ }
2637
+ };
2638
+ const aliasTargetsOf = (node) => {
2639
+ const modes = modesExtension(node);
2640
+ return [node["$value"], ...isRecord(modes) ? Object.values(modes) : []].map(aliasTarget).filter((target) => target !== void 0);
2641
+ };
2642
+ const modesExtension = (node) => {
2643
+ const extensions = node["$extensions"];
2644
+ return isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2645
+ };
2646
+ const isCompositeValue = (value) => typeof value === "object" && value !== null;
2647
+ const isScalar$1 = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
2648
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1830
2649
  const toVariable$1 = (name, node) => {
1831
2650
  const type = typeof node["$type"] === "string" ? node["$type"] : "";
1832
- const modes = readModes(node);
2651
+ const modes = readModes$1(node);
1833
2652
  const alias = aliasTarget(node["$value"]);
1834
2653
  const base = {
1835
2654
  name,
@@ -1846,37 +2665,64 @@ const toVariable$1 = (name, node) => {
1846
2665
  };
1847
2666
  };
1848
2667
  const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
1849
- const readModes = (node) => {
1850
- const extensions = node["$extensions"];
1851
- const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2668
+ const readModes$1 = (node) => {
2669
+ const modes = modesExtension(node);
1852
2670
  if (!isRecord(modes)) return void 0;
1853
2671
  const out = {};
1854
- for (const [name, value] of Object.entries(modes)) if (name.trim().length > 0 && typeof value === "string") out[name] = value;
2672
+ for (const [name, value] of Object.entries(modes)) if (isScalar$1(value)) out[name] = String(value);
1855
2673
  return Object.keys(out).length > 0 ? out : void 0;
1856
2674
  };
1857
- const toIncoming = (variable) => {
1858
- const isAlias = variable.ref !== void 0;
1859
- const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
1860
- const type = variable.type;
2675
+ const landingPath = (variable, known) => {
1861
2676
  const first = variable.name.split(".")[0] ?? "";
1862
- if (KNOWN_TIERS.has(first)) return withModes({
1863
- path: variable.name,
1864
- tier: first,
1865
- type,
1866
- value
1867
- }, variable);
1868
- const tier = isAlias ? "semantic" : "primitive";
1869
- return withModes({
1870
- path: `${tier}.${variable.name}`,
1871
- tier,
1872
- type,
2677
+ if (KNOWN_TIERS.has(first)) return variable.name;
2678
+ const existing = existingTierPath(variable.name, known);
2679
+ if (existing !== void 0) return existing;
2680
+ return `${variable.ref !== void 0 || Object.values(variable.modes ?? {}).some((modeValue) => aliasTarget(modeValue) !== void 0) ? "semantic" : "primitive"}.${variable.name}`;
2681
+ };
2682
+ const existingTierPath = (bare, known) => {
2683
+ const matches = TOKEN_TIERS.map((tier) => `${tier}.${bare}`).filter((path) => known.has(path));
2684
+ return matches.length === 1 ? matches[0] : void 0;
2685
+ };
2686
+ const refResolver = (neutral, known) => {
2687
+ const landings = /* @__PURE__ */ new Map();
2688
+ for (const variable of neutral.variables) landings.set(variable.name, landingPath(variable, known));
2689
+ return (ref) => {
2690
+ const first = ref.split(".")[0] ?? "";
2691
+ if (KNOWN_TIERS.has(first)) return ref;
2692
+ return landings.get(ref) ?? existingTierPath(ref, known) ?? ref;
2693
+ };
2694
+ };
2695
+ const toIncoming = (variable, resolveRef, known) => {
2696
+ const value = variable.ref === void 0 ? variable.value ?? "" : `{${resolveRef(variable.ref)}}`;
2697
+ const path = landingPath(variable, known);
2698
+ const token = {
2699
+ path,
2700
+ tier: path.split(".")[0],
2701
+ type: variable.type,
1873
2702
  value
1874
- }, variable);
2703
+ };
2704
+ if (!variable.modes) return token;
2705
+ const modes = {};
2706
+ for (const [mode, modeValue] of Object.entries(variable.modes)) {
2707
+ const alias = aliasTarget(modeValue);
2708
+ modes[mode] = alias === void 0 ? modeValue : `{${resolveRef(alias)}}`;
2709
+ }
2710
+ return {
2711
+ ...token,
2712
+ modes
2713
+ };
2714
+ };
2715
+ const incomingByPath = (neutral, known) => {
2716
+ const resolveRef = refResolver(neutral, known);
2717
+ const tokens = neutral.variables.map((variable) => ({
2718
+ exact: KNOWN_TIERS.has(variable.name.split(".")[0] ?? ""),
2719
+ token: toIncoming(variable, resolveRef, known)
2720
+ }));
2721
+ const byPath = /* @__PURE__ */ new Map();
2722
+ for (const { exact, token } of tokens) if (exact) byPath.set(token.path, token);
2723
+ for (const { exact, token } of tokens) if (!exact && !byPath.has(token.path)) byPath.set(token.path, token);
2724
+ return byPath;
1875
2725
  };
1876
- const withModes = (token, variable) => variable.modes ? {
1877
- ...token,
1878
- modes: variable.modes
1879
- } : token;
1880
2726
  const comparisonKey = (type, value, modes) => JSON.stringify({
1881
2727
  type,
1882
2728
  value,
@@ -1891,41 +2737,83 @@ const sortedModes = (modes) => {
1891
2737
  const diffNeutralAgainstJson = (neutral, parsed) => {
1892
2738
  const current = /* @__PURE__ */ new Map();
1893
2739
  for (const variable of dtcgToNeutral(parsed).variables) current.set(variable.name, variable);
2740
+ const unsynced = new Map(unsyncedTokens(parsed).map(({ path, reason }) => [path, reason]));
1894
2741
  const changes = [];
1895
- for (const variable of neutral.variables) {
1896
- const incoming = toIncoming(variable);
2742
+ for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced.keys()])).values()) {
2743
+ const reason = unsynced.get(incoming.path);
2744
+ if (reason !== void 0) {
2745
+ changes.push({
2746
+ path: incoming.path,
2747
+ tier: incoming.tier,
2748
+ kind: "changed",
2749
+ before: `(outside the sync: ${reason})`,
2750
+ after: incoming.value
2751
+ });
2752
+ continue;
2753
+ }
1897
2754
  const existing = current.get(incoming.path);
1898
2755
  const after = incoming.value;
1899
2756
  const before = existing ? neutralValue(existing) : void 0;
2757
+ const kind = classifyImport(existing, incoming);
1900
2758
  changes.push({
1901
2759
  path: incoming.path,
1902
2760
  tier: incoming.tier,
1903
- kind: classifyImport(existing, incoming, variable),
2761
+ kind,
1904
2762
  ...before !== void 0 ? { before } : {},
1905
- after
2763
+ after,
2764
+ ...existing && kind === "changed" ? changeDetail(existing, incoming) : {}
1906
2765
  });
1907
2766
  }
1908
2767
  return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1909
2768
  };
2769
+ const ownMode = (modes, mode) => Object.hasOwn(modes, mode) ? modes[mode] : void 0;
2770
+ const changeDetail = (existing, incoming) => {
2771
+ const detail = {};
2772
+ if (existing.type !== incoming.type) detail.typeChange = {
2773
+ before: existing.type,
2774
+ after: incoming.type
2775
+ };
2776
+ const before = existing.modes ?? {};
2777
+ const after = incoming.modes ?? {};
2778
+ const modeChanges = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].toSorted().filter((mode) => ownMode(before, mode) !== ownMode(after, mode)).map((mode) => {
2779
+ const was = ownMode(before, mode);
2780
+ const is = ownMode(after, mode);
2781
+ return {
2782
+ mode,
2783
+ ...was !== void 0 ? { before: was } : {},
2784
+ ...is !== void 0 ? { after: is } : {}
2785
+ };
2786
+ });
2787
+ if (modeChanges.length > 0) detail.modeChanges = modeChanges;
2788
+ return detail;
2789
+ };
1910
2790
  const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
1911
- const classifyImport = (existing, incoming, variable) => {
2791
+ const classifyImport = (existing, incoming) => {
1912
2792
  if (!existing) return "new";
1913
- return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, variable.modes) ? "unchanged" : "changed";
2793
+ return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, incoming.modes) ? "unchanged" : "changed";
1914
2794
  };
1915
2795
  const mergeNeutralIntoJson = (neutral, parsed, only) => {
1916
2796
  const merged = structuredClone(parsed);
1917
2797
  const onlySet = only ? new Set(only) : void 0;
1918
2798
  const written = [];
1919
2799
  const skipped = [];
1920
- for (const variable of neutral.variables) {
1921
- const incoming = toIncoming(variable);
2800
+ const unsyncedLanded = [];
2801
+ const current = new Map(dtcgToNeutral(parsed).variables.map((variable) => [variable.name, variable]));
2802
+ const unsynced = new Set(unsyncedTokens(parsed).map(({ path }) => path));
2803
+ for (const incoming of incomingByPath(neutral, /* @__PURE__ */ new Set([...current.keys(), ...unsynced])).values()) {
1922
2804
  if (onlySet && !onlySet.has(incoming.path)) continue;
2805
+ if (unsynced.has(incoming.path)) {
2806
+ unsyncedLanded.push(incoming.path);
2807
+ continue;
2808
+ }
2809
+ if (classifyImport(current.get(incoming.path), incoming) === "unchanged") continue;
1923
2810
  (setToken(merged, incoming) ? written : skipped).push(incoming.path);
1924
2811
  }
1925
2812
  return {
1926
2813
  merged,
1927
2814
  written: written.toSorted(),
1928
- skipped: skipped.toSorted()
2815
+ skipped: skipped.toSorted(),
2816
+ unsynced: unsyncedLanded.toSorted()
1929
2817
  };
1930
2818
  };
1931
2819
  const isTokenNode = (value) => isRecord(value) && "$value" in value;
@@ -1948,17 +2836,44 @@ const setToken = (root, incoming) => {
1948
2836
  if (!isRecord(next)) node[segment] = {};
1949
2837
  node = node[segment];
1950
2838
  }
1951
- const leaf = { $value: incoming.value };
2839
+ const existing = node[leafKey];
2840
+ const leaf = isRecord(existing) ? { ...existing } : {};
2841
+ const typeBefore = typeof leaf["$type"] === "string" ? leaf["$type"] : "";
2842
+ const sameType = incoming.type === typeBefore;
2843
+ const keep = (before, text) => sameType ? keptScalar(before, text) : text;
2844
+ leaf["$value"] = keep(leaf["$value"], incoming.value);
1952
2845
  if (incoming.type.length > 0) leaf["$type"] = incoming.type;
1953
- if (incoming.modes) leaf["$extensions"] = { [MODES_EXTENSION]: incoming.modes };
2846
+ else delete leaf["$type"];
2847
+ const extensions = isRecord(leaf["$extensions"]) ? { ...leaf["$extensions"] } : {};
2848
+ if (incoming.modes) {
2849
+ const before = extensions[MODES_EXTENSION];
2850
+ const modes = {};
2851
+ for (const [mode, value] of Object.entries(incoming.modes)) modes[mode] = keep(isRecord(before) && Object.hasOwn(before, mode) ? before[mode] : void 0, value);
2852
+ extensions[MODES_EXTENSION] = modes;
2853
+ } else delete extensions[MODES_EXTENSION];
2854
+ if (Object.keys(extensions).length > 0) leaf["$extensions"] = extensions;
2855
+ else delete leaf["$extensions"];
1954
2856
  node[leafKey] = leaf;
1955
2857
  return true;
1956
2858
  };
2859
+ const keptScalar = (before, incoming) => (typeof before === "number" || typeof before === "boolean") && String(before) === incoming ? before : incoming;
2860
+ const danglingAliasTargets = (parsed, paths) => {
2861
+ const { variables } = dtcgToNeutral(parsed);
2862
+ const names = new Set(variables.map((variable) => variable.name));
2863
+ const wanted = new Set(paths);
2864
+ const dangling = /* @__PURE__ */ new Set();
2865
+ for (const variable of variables) {
2866
+ if (!wanted.has(variable.name)) continue;
2867
+ const targets = [variable.ref, ...Object.values(variable.modes ?? {}).map(aliasTarget)];
2868
+ for (const target of targets) if (target !== void 0 && !names.has(target)) dangling.add(target);
2869
+ }
2870
+ return [...dangling].toSorted();
2871
+ };
1957
2872
  const upsertPlan = (parsed, toolState) => {
1958
- const current = /* @__PURE__ */ new Map();
1959
- for (const variable of toolState?.variables ?? []) current.set(variable.name, variable);
2873
+ const { variables } = dtcgToNeutral(parsed);
2874
+ const current = toolState ? incomingByPath(toolState, /* @__PURE__ */ new Set([...variables.map((variable) => variable.name), ...unsyncedTokens(parsed).map(({ path }) => path)])) : /* @__PURE__ */ new Map();
1960
2875
  const plan = [];
1961
- for (const variable of dtcgToNeutral(parsed).variables) plan.push({
2876
+ for (const variable of variables) plan.push({
1962
2877
  name: variable.name,
1963
2878
  kind: classifyExport(variable, current)
1964
2879
  });
@@ -1967,53 +2882,87 @@ const upsertPlan = (parsed, toolState) => {
1967
2882
  const classifyExport = (projected, current) => {
1968
2883
  const existing = current.get(projected.name);
1969
2884
  if (!existing) return "create";
1970
- return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, neutralValue(existing), existing.modes) ? "unchanged" : "update";
2885
+ return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, existing.value, existing.modes) ? "unchanged" : "update";
1971
2886
  };
1972
2887
  //#endregion
1973
2888
  //#region src/design-tokens/sync-io.ts
1974
- const parseNeutralFile = (raw) => {
2889
+ const parseNeutralFile = (raw, label) => {
1975
2890
  let parsed;
1976
2891
  try {
1977
2892
  parsed = JSON.parse(raw);
1978
2893
  } catch (error) {
1979
- throw new Error(`neutral file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2894
+ throw new Error(`${label} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1980
2895
  }
1981
- if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error("neutral file must be a JSON object with a \"variables\" array.");
2896
+ if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error(`${label} file must be a JSON object with a "variables" array.`);
1982
2897
  const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
1983
2898
  const provider = typeof parsed["provider"] === "string" ? parsed["provider"] : void 0;
2899
+ const variables = parsed["variables"].flatMap((entry) => toVariable(entry, label));
2900
+ const seen = /* @__PURE__ */ new Set();
2901
+ for (const { name } of variables) {
2902
+ if (seen.has(name)) throw new Error(`${label} file: variable "${name}" appears more than once — fix the adapter that wrote the file.`);
2903
+ seen.add(name);
2904
+ }
1984
2905
  return {
1985
2906
  schemaVersion,
1986
2907
  ...provider ? { provider } : {},
1987
- variables: parsed["variables"].flatMap(toVariable)
2908
+ variables
1988
2909
  };
1989
2910
  };
1990
- const toVariable = (raw) => {
2911
+ const toVariable = (raw, label) => {
1991
2912
  if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
1992
- const type = typeof raw["type"] === "string" ? raw["type"] : "";
2913
+ const name = raw["name"];
1993
2914
  const variable = {
1994
- name: raw["name"],
1995
- type
1996
- };
1997
- if (typeof raw["ref"] === "string") variable.ref = raw["ref"];
1998
- else if (typeof raw["value"] === "string") variable.value = raw["value"];
1999
- const modes = readStringMap(raw["modes"]);
2915
+ name,
2916
+ type: typeof raw["type"] === "string" ? raw["type"] : ""
2917
+ };
2918
+ const refuse = (fault, hint) => /* @__PURE__ */ new Error(`${label} file: variable "${name}" ${fault}. ${hint}`);
2919
+ const nameFault = tokenNameFault(name);
2920
+ if (nameFault !== void 0) throw refuse(nameFault, NEUTRAL_NAME_SHAPE);
2921
+ if (raw["ref"] !== void 0) {
2922
+ if (typeof raw["ref"] !== "string") throw refuse(`has ${jsonKind(raw["ref"])} ref`, NEUTRAL_VALUE_OR_REF);
2923
+ if (raw["ref"].trim().length === 0) throw refuse("has an empty ref", NEUTRAL_VALUE_OR_REF);
2924
+ variable.ref = raw["ref"];
2925
+ } else if (typeof raw["value"] === "string") variable.value = raw["value"];
2926
+ else if (raw["value"] !== void 0) throw refuse(`has ${jsonKind(raw["value"])} value`, NEUTRAL_STRING_VALUES);
2927
+ else throw refuse("has neither a value nor a ref", NEUTRAL_VALUE_OR_REF);
2928
+ const modes = readModes(raw["modes"], name, label);
2000
2929
  if (modes) variable.modes = modes;
2001
2930
  return [variable];
2002
2931
  };
2003
- const readStringMap = (raw) => {
2004
- if (!isRecord(raw)) return void 0;
2932
+ const readModes = (raw, name, label) => {
2933
+ if (raw === void 0) return void 0;
2934
+ if (!isRecord(raw)) throw new Error(`${label} file: variable "${name}" has ${jsonKind(raw)} "modes". ${NEUTRAL_MODES_OBJECT}`);
2005
2935
  const out = {};
2006
- for (const [key, value] of Object.entries(raw)) if (key.trim().length > 0 && typeof value === "string") out[key] = value;
2936
+ for (const [mode, value] of Object.entries(raw)) {
2937
+ if (mode.trim().length === 0 || mode === "__proto__") throw new Error(`${label} file: variable "${name}" has a mode named ${JSON.stringify(mode)}, which no mode can be named. ${NEUTRAL_MODES_OBJECT}`);
2938
+ if (typeof value !== "string") throw new Error(`${label} file: variable "${name}" has ${jsonKind(value)} value in mode "${mode}". ${NEUTRAL_STRING_VALUES}`);
2939
+ out[mode] = value;
2940
+ }
2007
2941
  return Object.keys(out).length > 0 ? out : void 0;
2008
2942
  };
2009
- const readJsonObject = (raw) => {
2943
+ const tokenNameFault = (name) => {
2944
+ const segments = name.split(".");
2945
+ if (segments.some((segment) => segment.trim().length === 0)) return "has an empty segment in its name";
2946
+ if (segments.some((segment) => segment.startsWith("$"))) return "has a segment starting with \"$\", which DTCG keeps for its own keys";
2947
+ if (segments.includes("__proto__")) return "has a \"__proto__\" segment, which no token can be named";
2948
+ if (segments.length === 1 && TIERS.has(name)) return `names only the tier "${name}", with no token under it`;
2949
+ };
2950
+ const TIERS = new Set(TOKEN_TIERS);
2951
+ const jsonKind = (value) => {
2952
+ if (value === null) return "null";
2953
+ if (Array.isArray(value)) return "an array";
2954
+ const kind = typeof value;
2955
+ return kind === "object" ? "an object" : `a ${kind}`;
2956
+ };
2957
+ const readJsonObject = (raw, tokenFile) => {
2010
2958
  let parsed;
2011
2959
  try {
2012
2960
  parsed = JSON.parse(raw);
2013
2961
  } catch (error) {
2014
- throw new Error(`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
2962
+ throw new Error(`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
2015
2963
  }
2016
- if (!isRecord(parsed)) throw new Error(`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`);
2964
+ if (!isRecord(parsed)) throw new Error(`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`);
2965
+ if (exceedsNestingLimit(parsed)) throw new Error(`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`);
2017
2966
  return parsed;
2018
2967
  };
2019
2968
  const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
@@ -2025,17 +2974,16 @@ const runImport = async (inputs) => {
2025
2974
  tokensFound: false,
2026
2975
  changes: [],
2027
2976
  applied: false,
2977
+ refused: false,
2028
2978
  written: [],
2029
2979
  skipped: [],
2030
- problems: []
2031
- };
2032
- if (!await pathExists(inputs.neutralPath)) return {
2033
- ...empty,
2034
- problems: [`neutral file not found: ${inputs.neutralPath}`]
2980
+ unsynced: [],
2981
+ problems: [],
2982
+ warnings: []
2035
2983
  };
2036
2984
  let neutral;
2037
2985
  try {
2038
- neutral = parseNeutralFile(await readFile(inputs.neutralPath, "utf8"));
2986
+ neutral = parseNeutralFile(await readRequiredFile(inputs.neutralPath, "neutral"), "neutral");
2039
2987
  } catch (error) {
2040
2988
  return {
2041
2989
  ...empty,
@@ -2046,39 +2994,92 @@ const runImport = async (inputs) => {
2046
2994
  ...empty,
2047
2995
  problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
2048
2996
  };
2049
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2050
- const tokensFound = await pathExists(tokenPath);
2997
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
2998
+ let tokensFound = false;
2999
+ let raw;
2051
3000
  let parsed;
2052
3001
  try {
2053
- parsed = tokensFound ? readJsonObject(await readFile(tokenPath, "utf8")) : {};
3002
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
3003
+ tokensFound = raw !== void 0;
3004
+ parsed = raw === void 0 ? {} : readJsonObject(raw, tokenFile);
2054
3005
  } catch (error) {
2055
3006
  return {
2056
3007
  ...empty,
2057
- tokensFound,
3008
+ tokensFound: true,
2058
3009
  problems: [asMessage$1(error)]
2059
3010
  };
2060
3011
  }
2061
3012
  const changes = diffNeutralAgainstJson(neutral, parsed);
2062
- if (!inputs.apply) return {
3013
+ if (!inputs.apply) {
3014
+ const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
3015
+ return {
3016
+ ...empty,
3017
+ tokensFound,
3018
+ changes,
3019
+ warnings: faults === void 0 ? [] : [`${tokenFile} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
3020
+ };
3021
+ }
3022
+ const unmatched = (inputs.only ?? []).filter((path) => !changes.some((change) => change.path === path));
3023
+ if (unmatched.length > 0) return {
3024
+ ...empty,
2063
3025
  tokensFound,
2064
3026
  changes,
2065
- applied: false,
2066
- written: [],
2067
- skipped: [],
2068
- problems: []
3027
+ refused: true,
3028
+ problems: [`--only names ${unmatched.length === 1 ? "a path" : "paths"} no variable in the neutral file lands at: ${unmatched.join(", ")}. Use the tiered paths the preview prints; nothing was written.`]
2069
3029
  };
2070
- const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
2071
- await mkdir(dirname(tokenPath), { recursive: true });
2072
- await writeFile(tokenPath, writeJsonFile(merged));
3030
+ const { merged, written, skipped, unsynced } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
3031
+ if (exceedsNestingLimit(merged)) return {
3032
+ ...empty,
3033
+ tokensFound,
3034
+ changes,
3035
+ refused: true,
3036
+ problems: [`not applied — with this import, ${tokenFile} would fail validation: ${nestedTooDeeply(tokenFile)} ${IMPORT_TOO_DEEP_HINT}`]
3037
+ };
3038
+ const text = writeJsonFile(merged);
3039
+ const mergedViolations = tokenFileViolations(text, tokenFile);
3040
+ const mergedFaults = [...nonFiniteNumberFaults(merged, tokenFile), ...mergedViolations.map((violation) => violation.message)];
3041
+ if (mergedFaults.length > 0) {
3042
+ const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
3043
+ const introduced = mergedFaults.filter((fault) => !onDisk.has(fault));
3044
+ const landings = new Set(changes.map((change) => change.path));
3045
+ const dangling = danglingAliasTargets(merged, written.filter((path) => !path.startsWith("primitive.")));
3046
+ const leftBehind = dangling.filter((target) => landings.has(target));
3047
+ const notCarried = dangling.filter((target) => !landings.has(target));
3048
+ const danglingFaults = new Set(mergedViolations.filter((violation) => violation.kind === "unresolved-alias").map((violation) => violation.message));
3049
+ const hint = importRefusalHint(leftBehind, notCarried, introduced.some((fault) => !danglingFaults.has(fault)), tokenFile);
3050
+ return {
3051
+ ...empty,
3052
+ tokensFound,
3053
+ changes,
3054
+ refused: true,
3055
+ problems: [introduced.length > 0 ? `not applied — with this import, ${tokenFile} would fail validation: ${summarizeMessages(introduced)} ${hint}` : `not applied — ${tokenFile} already fails validation and this import does not repair it: ${summarizeMessages(mergedFaults)} ${VALIDATE_HINT}`]
3056
+ };
3057
+ }
3058
+ if (written.length > 0) {
3059
+ const tokenPath = join(inputs.repoRoot, tokenFile);
3060
+ await mkdir(dirname(tokenPath), { recursive: true });
3061
+ await writeFile(tokenPath, text);
3062
+ }
2073
3063
  return {
2074
3064
  tokensFound,
2075
3065
  changes,
2076
3066
  applied: true,
3067
+ refused: false,
2077
3068
  written,
2078
3069
  skipped,
2079
- problems: []
3070
+ unsynced,
3071
+ problems: [],
3072
+ warnings: []
2080
3073
  };
2081
3074
  };
3075
+ const importRefusalHint = (leftBehind, notCarried, otherFault, tokenFile) => {
3076
+ if (notCarried.length === 0 && !otherFault) return IMPORT_INVALID_HINT;
3077
+ return `${[
3078
+ ...leftBehind.length > 0 ? [`Add ${leftBehind.join(", ")} to the \`--only\` slice`] : [],
3079
+ ...notCarried.length > 0 ? [`The neutral file carries no variable that lands at ${notCarried.join(", ")}, so no \`--only\` slice can supply ${notCarried.length === 1 ? "it" : "them"}: add the token in the design tool (or in ${tokenFile}) first, or leave out what references it`] : [],
3080
+ ...otherFault ? [IMPORT_FAULT_CLAUSE] : []
3081
+ ].join(". ")}; nothing was written.`;
3082
+ };
2082
3083
  const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
2083
3084
  //#endregion
2084
3085
  //#region src/design-tokens/drift.ts
@@ -2130,15 +3131,23 @@ const runExport = async (inputs) => {
2130
3131
  const empty = {
2131
3132
  tokensFound: false,
2132
3133
  plan: [],
3134
+ unprojected: [],
2133
3135
  hash: "",
2134
3136
  recorded: false,
2135
3137
  problems: []
2136
3138
  };
2137
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
2138
- if (!await pathExists(tokenPath)) return empty;
3139
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
2139
3140
  let parsed;
2140
3141
  try {
2141
- parsed = readJsonObject(await readFile(tokenPath, "utf8"));
3142
+ const raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
3143
+ if (raw === void 0) return empty;
3144
+ parsed = readJsonObject(raw, tokenFile);
3145
+ const invalid = summarizeTokenFileViolations(raw, tokenFile);
3146
+ if (invalid !== void 0) return {
3147
+ ...empty,
3148
+ tokensFound: true,
3149
+ problems: [`${tokenFile} fails validation: ${invalid} ${VALIDATE_HINT}`]
3150
+ };
2142
3151
  } catch (error) {
2143
3152
  return {
2144
3153
  ...empty,
@@ -2150,39 +3159,50 @@ const runExport = async (inputs) => {
2150
3159
  if (!binding) return {
2151
3160
  ...empty,
2152
3161
  tokensFound: true,
2153
- problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${DESIGN_TOKENS_FILE} before exporting.`]
3162
+ problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${tokenFile} before exporting.`]
2154
3163
  };
2155
3164
  const hash = projectionHash(parsed);
2156
3165
  let toolState;
2157
- if (inputs.toolStatePath !== void 0) {
2158
- if (!await pathExists(inputs.toolStatePath)) return {
3166
+ if (inputs.toolStatePath !== void 0) try {
3167
+ toolState = parseNeutralFile(await readRequiredFile(inputs.toolStatePath, "tool-state"), "tool-state");
3168
+ } catch (error) {
3169
+ return {
3170
+ ...empty,
3171
+ tokensFound: true,
3172
+ hash,
3173
+ problems: [asMessage(error)]
3174
+ };
3175
+ }
3176
+ const plan = upsertPlan(parsed, toolState);
3177
+ const unprojected = unsyncedTokens(parsed);
3178
+ if (inputs.outPath !== void 0) {
3179
+ const projection = dtcgToNeutral(parsed);
3180
+ projection.provider = binding.provider;
3181
+ if (await sameEntry(inputs.outPath, join(inputs.repoRoot, tokenFile))) return {
2159
3182
  ...empty,
2160
3183
  tokensFound: true,
2161
3184
  hash,
2162
- problems: [`tool-state file not found: ${inputs.toolStatePath}`]
3185
+ problems: [`Cannot write the projection to ${inputs.outPath}: it is ${tokenFile} itself, which the projection would replace. Name another path.`]
2163
3186
  };
2164
3187
  try {
2165
- toolState = parseNeutralFile(await readFile(inputs.toolStatePath, "utf8"));
3188
+ await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
2166
3189
  } catch (error) {
2167
3190
  return {
2168
3191
  ...empty,
2169
3192
  tokensFound: true,
2170
3193
  hash,
2171
- problems: [asMessage(error)]
3194
+ problems: [`Cannot write the projection to ${inputs.outPath}: ${asMessage(error)}`]
2172
3195
  };
2173
3196
  }
2174
3197
  }
2175
- const plan = upsertPlan(parsed, toolState);
2176
- if (inputs.outPath !== void 0) {
2177
- const projection = dtcgToNeutral(parsed);
2178
- projection.provider = binding.provider;
2179
- await writeFile(inputs.outPath, writeNeutralFile(projection));
2180
- }
2181
3198
  if (inputs.record) {
2182
- await writeFile(tokenPath, writeJsonFile(stampBaseline(parsed, hash)));
3199
+ const stamped = writeJsonFile(stampBaseline(parsed, hash));
3200
+ const tokenPath = join(inputs.repoRoot, tokenFile);
3201
+ await writeFile(tokenPath, stamped);
2183
3202
  return {
2184
3203
  tokensFound: true,
2185
3204
  plan,
3205
+ unprojected,
2186
3206
  hash,
2187
3207
  recorded: true,
2188
3208
  problems: []
@@ -2191,13 +3211,14 @@ const runExport = async (inputs) => {
2191
3211
  return {
2192
3212
  tokensFound: true,
2193
3213
  plan,
3214
+ unprojected,
2194
3215
  hash,
2195
3216
  recorded: false,
2196
3217
  problems: []
2197
3218
  };
2198
3219
  };
2199
3220
  const stampBaseline = (parsed, hash) => {
2200
- const clone = structuredClone(parsed);
3221
+ const clone = { ...parsed };
2201
3222
  const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
2202
3223
  const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
2203
3224
  binding["lastProjected"] = hash;
@@ -2205,8 +3226,57 @@ const stampBaseline = (parsed, hash) => {
2205
3226
  clone["$extensions"] = extensions;
2206
3227
  return clone;
2207
3228
  };
3229
+ const sameEntry = async (a, b) => {
3230
+ const [left, right] = await Promise.all([a, b].map((path) => stat(path).catch(() => void 0)));
3231
+ return left !== void 0 && right !== void 0 && left.dev === right.dev && left.ino === right.ino;
3232
+ };
2208
3233
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2209
3234
  //#endregion
3235
+ //#region src/review-ledger/parse-markdown-blocks.ts
3236
+ const MAX_NESTING = 200;
3237
+ const MARKDOWN = new MarkdownIt("commonmark", { maxNesting: MAX_NESTING });
3238
+ const OPEN_SUFFIX = /_open$/;
3239
+ const parseMarkdownBlocks = (lines) => {
3240
+ const root = {
3241
+ type: "root",
3242
+ start: 0,
3243
+ end: lines.length,
3244
+ markup: "",
3245
+ text: "",
3246
+ content: "",
3247
+ children: []
3248
+ };
3249
+ const stack = [root];
3250
+ const tokens = MARKDOWN.parse(lines.join("\n"), {});
3251
+ const cut = tokens.find((token) => token.nesting === 1 && token.level >= 199);
3252
+ if (cut) throw new Error(`line ${(cut.map?.[0] ?? 0) + 1}: the markdown nests deeper than ${MAX_NESTING} parser levels (a nested list costs two, a quote one), so what it holds was not read.`);
3253
+ for (const token of tokens) {
3254
+ const parent = stack[stack.length - 1] ?? root;
3255
+ if (token.nesting === -1) {
3256
+ stack.pop();
3257
+ continue;
3258
+ }
3259
+ if (token.type === "inline") {
3260
+ parent.text = plainText(token.children ?? []);
3261
+ continue;
3262
+ }
3263
+ const [start, end] = token.map ?? [parent.start, parent.start];
3264
+ const block = {
3265
+ type: token.type.replace(OPEN_SUFFIX, ""),
3266
+ start,
3267
+ end,
3268
+ markup: token.markup,
3269
+ text: "",
3270
+ content: token.content,
3271
+ children: []
3272
+ };
3273
+ parent.children.push(block);
3274
+ if (token.nesting === 1) stack.push(block);
3275
+ }
3276
+ return root.children;
3277
+ };
3278
+ const plainText = (children) => children.filter((child) => child.type === "text" || child.type === "code_inline").map((child) => child.content).join("");
3279
+ //#endregion
2210
3280
  //#region src/review-ledger/review-ledger.constant.ts
2211
3281
  const REVIEW_LEDGER_DIRNAME = "review-ledger";
2212
3282
  const FULL_PASS_FILENAME = "full-pass.json";
@@ -2217,18 +3287,29 @@ const SPEC_SIDE_KINDS = [
2217
3287
  "group-missing",
2218
3288
  "group-empty",
2219
3289
  "duplicate-group-index",
3290
+ "duplicate-task-id",
2220
3291
  "orphan-task",
2221
3292
  "malformed-risk-marker",
2222
3293
  "malformed-task-refs",
3294
+ "malformed-task-line",
3295
+ "malformed-group-header",
2223
3296
  "unknown-risk-class",
2224
3297
  "dangling-requirement-ref",
2225
3298
  "unticked-completed-task"
2226
3299
  ];
2227
3300
  const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
3301
+ const GROUP_HEADER_LOOKALIKE = /^ {0,3}#+\s*group(?:\s*\d|\s+\S+\s*(?:—|$))/i;
3302
+ const GROUP_SHAPED_HEADING = /^ {0,3}##\s+[^\s\d]\S*\s+\d.*(?:[_*]\(.*\)[_*]|\[\s*risks?\s*:)/i;
3303
+ const LINE_CONTAINER_MARKERS = /^(?:\s*(?:>|[-+*](?=\s)|\d{1,9}[.)](?=\s)))+\s*/;
2228
3304
  const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2229
3305
  const RISK_MARKER_ALL = /\[risk:/gi;
2230
3306
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2231
- const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
3307
+ const TASK_LINE = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*|__|`)?\s*(T\d+)\b(?![.-]\w)/;
3308
+ const TASK_LINE_LOOKALIKE = /^\s*(?:[-*+]|\d+[.)])\s*\[[^\]]*\]\s*[*_`]*\s*T\d/;
3309
+ const BOXLESS_TASK_LINE = /^\s*(?:[-*+]|\d+[.)])\s+[*_`]*\s*T\d/;
3310
+ const TASK_BOX = /\[([^\]]*)\]/;
3311
+ const TASK_LOOKALIKE_ID = /\]\s*[*_`]*\s*(T\d+(?:[.-]?\w)*)/;
3312
+ const LINE_BOM = /^\uFEFF/;
2232
3313
  const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2233
3314
  const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2234
3315
  const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
@@ -2254,42 +3335,66 @@ const NOT_APPLICABLE_REASONS = [
2254
3335
  ];
2255
3336
  //#endregion
2256
3337
  //#region src/review-ledger/parse-spec.ts
3338
+ const VERBATIM_BLOCKS = /* @__PURE__ */ new Set([
3339
+ "fence",
3340
+ "code_block",
3341
+ "html_block"
3342
+ ]);
3343
+ const CODE_BLOCKS = /* @__PURE__ */ new Set(["fence", "code_block"]);
3344
+ const HIDDEN_HTML_SPAN = /<!--[\s\S]*?(?:-->|$)|<(script|style|textarea)(?=[\s/>]|$)[\s\S]*?(?:<\/\1\s*>|$)/gi;
3345
+ const HANGING_INDENT = /^ {4}/;
3346
+ const LEADING_WHITESPACE = /^[ \t]*/;
3347
+ const LINE_BREAK = /\r\n?|\n/;
3348
+ const LEADING_SPACES = /^ */;
2257
3349
  const parseTasksSpec = (text) => {
2258
- const lines = text.split(/\r?\n/);
2259
- const grouped = lines.some((line) => GROUP_HEADER.test(line));
3350
+ const { lines, blocks } = withHangingIndentsRead(text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, "")));
3351
+ const malformedTaskLines = [];
3352
+ const malformedGroupHeaders = [];
3353
+ const entries = readSpecEntries(lines, blocks, malformedTaskLines, malformedGroupHeaders);
3354
+ const grouped = entries.some((entry) => entry.kind !== "task");
2260
3355
  const groups = [];
2261
3356
  const orphanTaskIds = [];
2262
3357
  const malformedRiskHeaders = [];
2263
3358
  const malformedTaskRefs = [];
3359
+ const taskIdGroups = /* @__PURE__ */ new Map();
3360
+ const record = (id, group) => {
3361
+ taskIdGroups.set(id, [...taskIdGroups.get(id) ?? [], group]);
3362
+ };
2264
3363
  let current;
2265
- for (let index = 0; index < lines.length; index += 1) {
2266
- const line = lines[index] ?? "";
2267
- const header = GROUP_HEADER.exec(line);
2268
- if (header) {
2269
- const risk = parseRiskMarker(line);
2270
- if (risk.malformed) malformedRiskHeaders.push(line.trim());
3364
+ let quarantined = false;
3365
+ for (const entry of entries) {
3366
+ if (entry.kind === "group") {
3367
+ const risk = parseRiskMarker(entry.line);
3368
+ if (risk.malformed) malformedRiskHeaders.push(entry.line.trim());
2271
3369
  current = {
2272
- index: Number(header[1]),
2273
- header: line.trim(),
3370
+ index: Number(GROUP_HEADER.exec(entry.line)?.[1]),
3371
+ header: entry.line.trim(),
2274
3372
  riskClasses: risk.riskClasses,
2275
3373
  unknownRiskClasses: risk.unknownRiskClasses,
2276
3374
  tasks: []
2277
3375
  };
2278
3376
  groups.push(current);
3377
+ quarantined = false;
3378
+ continue;
3379
+ }
3380
+ if (entry.kind === "not-a-group") {
3381
+ quarantined = true;
3382
+ continue;
3383
+ }
3384
+ const { id } = entry;
3385
+ if (quarantined) {
3386
+ record(id, null);
2279
3387
  continue;
2280
3388
  }
2281
- const match = TASK_LINE.exec(line);
2282
- if (!match) continue;
2283
- const blockEnd = taskBlockEnd(lines, index);
2284
- const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2285
- index = blockEnd - 1;
2286
- const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
3389
+ const task = parseTaskBlock(id, entry.ticked, entry.parts, entry.refInCode);
2287
3390
  if (task.malformedRefs) malformedTaskRefs.push(task.id);
2288
3391
  if (current) {
3392
+ record(id, current.index);
2289
3393
  current.tasks.push(task.task);
2290
3394
  continue;
2291
3395
  }
2292
3396
  if (grouped) {
3397
+ record(id, null);
2293
3398
  orphanTaskIds.push(task.id);
2294
3399
  continue;
2295
3400
  }
@@ -2300,6 +3405,7 @@ const parseTasksSpec = (text) => {
2300
3405
  unknownRiskClasses: [],
2301
3406
  tasks: [task.task]
2302
3407
  });
3408
+ record(id, groups.length);
2303
3409
  }
2304
3410
  const seen = /* @__PURE__ */ new Set();
2305
3411
  const duplicateIndexes = [];
@@ -2311,20 +3417,282 @@ const parseTasksSpec = (text) => {
2311
3417
  groups,
2312
3418
  orphanTaskIds,
2313
3419
  duplicateIndexes,
3420
+ duplicateTaskIds: [...taskIdGroups].filter(([, copies]) => copies.length > 1).map(([id, copies]) => ({
3421
+ id,
3422
+ groups: copies
3423
+ })),
2314
3424
  malformedRiskHeaders,
2315
- malformedTaskRefs
3425
+ malformedTaskRefs,
3426
+ malformedTaskLines,
3427
+ malformedGroupHeaders
3428
+ };
3429
+ };
3430
+ const readSpecEntries = (lines, blocks, malformedTaskLines, malformedGroupHeaders) => {
3431
+ const entries = [];
3432
+ const lineAt = (index) => lines[index] ?? "";
3433
+ const samples = quotedSampleLines(lines, blocks);
3434
+ const notAGroup = (header, zero) => {
3435
+ malformedGroupHeaders.push({
3436
+ header: header.trim(),
3437
+ defect: zero ? "zero" : "lookalike"
3438
+ });
3439
+ entries.push({ kind: "not-a-group" });
3440
+ };
3441
+ const notATask = (line, owner) => {
3442
+ if (owner !== void 0) {
3443
+ malformedTaskLines.push({
3444
+ line: line.trim(),
3445
+ defect: "nested",
3446
+ parentId: owner
3447
+ });
3448
+ return;
3449
+ }
3450
+ malformedTaskLines.push({
3451
+ line: line.trim(),
3452
+ defect: taskLineDefect(line, writtenIdOf(line))
3453
+ });
2316
3454
  };
2317
- };
2318
- const taskBlockEnd = (lines, start) => {
2319
- let end = start + 1;
2320
- while (end < lines.length) {
2321
- const line = lines[end] ?? "";
2322
- if (line.trim() === "" || !/^\s/.test(line) || TASK_LINE.test(line) || GROUP_HEADER.test(line)) break;
2323
- end += 1;
3455
+ const readVerbatim = (block) => {
3456
+ const shown = hidesHtml(block) ? shownPastHiddenHtml(block.content) : [];
3457
+ block.content.split("\n").forEach((line, offset) => {
3458
+ const text = (shown[offset] ?? "").trimStart();
3459
+ if (TASK_LINE_LOOKALIKE.test(line)) malformedTaskLines.push({
3460
+ line: line.trim(),
3461
+ defect: "fenced"
3462
+ });
3463
+ else if (isGroupShaped(line)) malformedGroupHeaders.push({
3464
+ header: line.trim(),
3465
+ defect: "fenced"
3466
+ });
3467
+ else if (TASK_LINE_LOOKALIKE.test(text)) malformedTaskLines.push({
3468
+ line: text.trim(),
3469
+ defect: "shape"
3470
+ });
3471
+ else if (isGroupShaped(text)) malformedGroupHeaders.push({
3472
+ header: text.trim(),
3473
+ defect: "lookalike"
3474
+ });
3475
+ });
3476
+ };
3477
+ const readHeading = (block) => {
3478
+ const line = lineAt(block.start);
3479
+ const header = GROUP_HEADER.exec(line);
3480
+ if (header && Number(header[1]) > 0) {
3481
+ entries.push({
3482
+ kind: "group",
3483
+ line
3484
+ });
3485
+ return;
3486
+ }
3487
+ const shown = `${headingMarks(block.markup)} ${block.text}`;
3488
+ const own = line.replace(LINE_CONTAINER_MARKERS, "");
3489
+ if (![
3490
+ line,
3491
+ shown,
3492
+ block.markup.startsWith("#") ? own : `${headingMarks(block.markup)} ${own}`
3493
+ ].some(isGroupShaped)) return;
3494
+ notAGroup(line, isGroupZero(shown));
3495
+ };
3496
+ const readParagraph = (block, owner, itemLine) => {
3497
+ for (let index = block.start; index < block.end; index += 1) {
3498
+ if (index === itemLine) continue;
3499
+ const line = lineAt(index);
3500
+ if (isGroupShaped(line)) notAGroup(line, false);
3501
+ else if (TASK_LINE_LOOKALIKE.test(line)) notATask(line, owner);
3502
+ else if (samples.has(index) && TASK_LINE_LOOKALIKE.test(unquoted(line))) malformedTaskLines.push({
3503
+ line: line.trim(),
3504
+ defect: "shape"
3505
+ });
3506
+ else if (owner === void 0 && BOXLESS_TASK_LINE.test(line)) malformedTaskLines.push({
3507
+ line: line.trim(),
3508
+ defect: "shape"
3509
+ });
3510
+ }
3511
+ };
3512
+ const readItem = (block, owner) => {
3513
+ const line = lineAt(block.start);
3514
+ if (TASK_LINE_LOOKALIKE.test(line)) {
3515
+ const writtenId = writtenIdOf(line);
3516
+ const match = TASK_LINE.exec(line);
3517
+ const id = match?.[2];
3518
+ if (owner !== void 0 || !match || id === void 0) notATask(line, owner);
3519
+ else entries.push({
3520
+ kind: "task",
3521
+ id,
3522
+ ticked: (match[1] ?? " ") !== " ",
3523
+ ...taskText(lines, block)
3524
+ });
3525
+ readBlocks(block.children, owner ?? writtenId, block.start);
3526
+ return;
3527
+ }
3528
+ if (isQuotedTask(lines, block)) {
3529
+ malformedTaskLines.push({
3530
+ line: line.trim(),
3531
+ defect: "shape"
3532
+ });
3533
+ readBlocks(block.children, owner, block.start);
3534
+ return;
3535
+ }
3536
+ if (owner === void 0 && BOXLESS_TASK_LINE.test(line)) malformedTaskLines.push({
3537
+ line: line.trim(),
3538
+ defect: "shape"
3539
+ });
3540
+ readBlocks(block.children, owner, block.start);
3541
+ };
3542
+ const readBlocks = (siblings, owner, itemLine) => {
3543
+ for (const block of siblings) if (VERBATIM_BLOCKS.has(block.type)) readVerbatim(block);
3544
+ else if (block.type === "heading") readHeading(block);
3545
+ else if (block.type === "paragraph") readParagraph(block, owner, itemLine);
3546
+ else if (block.type === "list_item") readItem(block, owner);
3547
+ else readBlocks(block.children, owner, -1);
3548
+ };
3549
+ readBlocks(blocks, void 0, -1);
3550
+ return entries;
3551
+ };
3552
+ const withHangingIndentsRead = (source) => {
3553
+ const lines = source.map(withLeadingTabsExpanded);
3554
+ for (;;) {
3555
+ const blocks = parseMarkdownBlocks(lines);
3556
+ const shifting = codeInTasks(lines, blocks, void 0).filter(({ block, task }) => !(block.end >= task.end && continuesParagraph(lines[block.end]))).filter(({ block }) => canShift(lines, block)).map(({ block }) => block);
3557
+ if (shifting.length === 0) return {
3558
+ lines,
3559
+ blocks
3560
+ };
3561
+ const rounds = shifting.reduce((fewest, block) => Math.min(fewest, shiftsLeavingItCode(lines, block)), Number.POSITIVE_INFINITY);
3562
+ let shifted = false;
3563
+ for (const block of shifting) for (let index = block.start; index < block.end; index += 1) {
3564
+ let line = lines[index] ?? "";
3565
+ for (let n = 0; n < rounds && HANGING_INDENT.test(line); n += 1) {
3566
+ line = line.replace(HANGING_INDENT, "");
3567
+ shifted = true;
3568
+ }
3569
+ lines[index] = line;
3570
+ }
3571
+ if (!shifted) return {
3572
+ lines,
3573
+ blocks
3574
+ };
2324
3575
  }
2325
- return end;
2326
3576
  };
2327
- const parseTaskBlock = (id, ticked, parts) => {
3577
+ const canShift = (lines, block) => {
3578
+ const content = block.content.split("\n");
3579
+ return lines.slice(block.start, block.end).every((line, offset) => line.trim() === "" || line.replace(/\0/g, "�").trimStart() === (content[offset] ?? "").trimStart());
3580
+ };
3581
+ const shiftsLeavingItCode = (lines, block) => {
3582
+ const content = block.content.split("\n");
3583
+ let threshold;
3584
+ let least = Number.POSITIVE_INFINITY;
3585
+ for (let index = block.start; index < block.end; index += 1) {
3586
+ const line = lines[index] ?? "";
3587
+ if (line.trim() === "") continue;
3588
+ const code = content[index - block.start] ?? "";
3589
+ const indent = LEADING_SPACES.exec(line)?.[0].length ?? 0;
3590
+ if (line.slice(indent) !== code.trimStart()) return 1;
3591
+ threshold = indent - (LEADING_SPACES.exec(code)?.[0].length ?? 0);
3592
+ least = Math.min(least, indent);
3593
+ }
3594
+ if (threshold === void 0) return 1;
3595
+ return Math.floor((least - threshold) / 4) + 1;
3596
+ };
3597
+ const withLeadingTabsExpanded = (line) => {
3598
+ const [lead = ""] = LEADING_WHITESPACE.exec(line) ?? [];
3599
+ if (!lead.includes(" ")) return line;
3600
+ let column = 0;
3601
+ for (const char of lead) column = char === " " ? column + 4 - column % 4 : column + 1;
3602
+ return " ".repeat(column) + line.slice(lead.length);
3603
+ };
3604
+ const codeInTasks = (lines, blocks, task) => blocks.flatMap((block) => {
3605
+ if (block.type === "code_block") return task ? [{
3606
+ block,
3607
+ task
3608
+ }] : [];
3609
+ const opens = block.type === "list_item" && TASK_LINE_LOOKALIKE.test(lines[block.start] ?? "");
3610
+ return codeInTasks(lines, block.children, task ?? (opens ? block : void 0));
3611
+ });
3612
+ const taskText = (lines, item) => {
3613
+ const nested = /* @__PURE__ */ new Set();
3614
+ const code = /* @__PURE__ */ new Set();
3615
+ const shown = /* @__PURE__ */ new Map();
3616
+ const exclude = (blocks) => {
3617
+ for (const block of blocks) {
3618
+ const hidden = hidesHtml(block);
3619
+ if (hidden) shownPastHiddenHtml(block.content).forEach((text, offset) => {
3620
+ if (text.trim() !== "") shown.set(block.start + offset, text);
3621
+ });
3622
+ const into = CODE_BLOCKS.has(block.type) || hidden ? code : block.type === "list_item" && (TASK_LINE_LOOKALIKE.test(lines[block.start] ?? "") || isQuotedTask(lines, block)) ? nested : void 0;
3623
+ if (!into) {
3624
+ exclude(block.children);
3625
+ continue;
3626
+ }
3627
+ for (let index = block.start; index < block.end; index += 1) into.add(index);
3628
+ }
3629
+ };
3630
+ exclude(item.children);
3631
+ const samples = quotedSampleLines(lines, item.children);
3632
+ const parts = [(lines[item.start] ?? "").trim()];
3633
+ let refInCode = false;
3634
+ for (let index = item.start + 1; index < item.end; index += 1) {
3635
+ const line = lines[index] ?? "";
3636
+ if (nested.has(index) || line.trim() === "") continue;
3637
+ if (code.has(index)) {
3638
+ refInCode ||= hasRefLookalike([line]);
3639
+ const text = shown.get(index);
3640
+ if (text !== void 0 && !isTaskOrHeaderShaped(text.trimStart())) parts.push(text.trim());
3641
+ continue;
3642
+ }
3643
+ if (isTaskOrHeaderShaped(line) || samples.has(index)) continue;
3644
+ parts.push(line.trim());
3645
+ }
3646
+ return {
3647
+ parts,
3648
+ refInCode
3649
+ };
3650
+ };
3651
+ const hidesHtml = (block) => block.type === "html_block" && shownPastHiddenHtml(block.content).join("\n") !== block.content;
3652
+ const shownPastHiddenHtml = (content) => content.replace(HIDDEN_HTML_SPAN, (span) => span.replace(/[^\n]/g, "")).split("\n");
3653
+ const isTaskOrHeaderShaped = (line) => TASK_LINE_LOOKALIKE.test(line) || isGroupShaped(line);
3654
+ const isQuotedTask = (lines, item) => {
3655
+ const line = lines[item.start] ?? "";
3656
+ return !TASK_LINE_LOOKALIKE.test(line) && TASK_LINE_LOOKALIKE.test(unquoted(line)) && !opensItemOn(item.children, item.start);
3657
+ };
3658
+ const quotedSampleLines = (lines, blocks) => {
3659
+ const sample = /* @__PURE__ */ new Set();
3660
+ const visit = (siblings, inQuote) => {
3661
+ for (const block of siblings) {
3662
+ if (inQuote && block.type === "paragraph") {
3663
+ let folded = false;
3664
+ for (let index = block.start; index < block.end; index += 1) {
3665
+ const line = lines[index] ?? "";
3666
+ folded ||= TASK_LINE_LOOKALIKE.test(unquoted(line));
3667
+ if (folded) sample.add(index);
3668
+ }
3669
+ }
3670
+ visit(block.children, inQuote || block.type === "blockquote");
3671
+ }
3672
+ };
3673
+ visit(blocks, false);
3674
+ return sample;
3675
+ };
3676
+ const opensItemOn = (blocks, start) => blocks.some((block) => block.type === "list_item" && block.start === start || opensItemOn(block.children, start));
3677
+ const unquoted = (line) => {
3678
+ const markers = LINE_CONTAINER_MARKERS.exec(line)?.[0] ?? "";
3679
+ return line.slice(markers.lastIndexOf(">") + 1);
3680
+ };
3681
+ const continuesParagraph = (line) => {
3682
+ if (line === void 0 || line.trim() === "") return false;
3683
+ const [only, ...rest] = parseMarkdownBlocks(["text", line]);
3684
+ return rest.length === 0 && (only?.type === "paragraph" || only?.type === "heading" && only.markup.startsWith("=")) && only.end === 2;
3685
+ };
3686
+ const headingMarks = (markup) => markup.startsWith("#") ? markup : markup.startsWith("=") ? "#" : "##";
3687
+ const isGroupShaped = (line) => GROUP_HEADER.test(line) || GROUP_HEADER_LOOKALIKE.test(line) || GROUP_SHAPED_HEADING.test(line);
3688
+ const isGroupZero = (line) => Number(GROUP_HEADER.exec(line)?.[1] ?? NaN) === 0;
3689
+ const writtenIdOf = (line) => TASK_LOOKALIKE_ID.exec(line)?.[1] ?? "";
3690
+ const taskLineDefect = (line, writtenId) => {
3691
+ if (!/^[ xX]$/.test(TASK_BOX.exec(line)?.[1] ?? "")) return "checkbox";
3692
+ if (!/^T\d+$/.test(writtenId)) return "id";
3693
+ return "shape";
3694
+ };
3695
+ const parseTaskBlock = (id, ticked, parts, refInCode) => {
2328
3696
  const block = parts.join(" ");
2329
3697
  const opener = refsOpener(parts, block);
2330
3698
  const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
@@ -2336,7 +3704,7 @@ const parseTaskBlock = (id, ticked, parts) => {
2336
3704
  ticked,
2337
3705
  requirementRefs: [...new Set(requirementRefs)]
2338
3706
  },
2339
- malformedRefs: refs === null && hasRefLookalike(parts)
3707
+ malformedRefs: refs === null && (refInCode || hasRefLookalike(parts))
2340
3708
  };
2341
3709
  };
2342
3710
  const declarationAt = (parts, block, opener) => {
@@ -2418,9 +3786,11 @@ const parseRiskMarker = (line) => {
2418
3786
  };
2419
3787
  };
2420
3788
  const parseRequirementIds = (text) => {
3789
+ const lines = text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, ""));
2421
3790
  const ids = [];
2422
- for (const line of text.split(/\r?\n/)) {
2423
- const id = REQUIREMENT_LINE.exec(line)?.[1];
3791
+ for (const list of parseMarkdownBlocks(lines)) for (const item of list.children) {
3792
+ if (item.type !== "list_item") continue;
3793
+ const id = REQUIREMENT_LINE.exec(lines[item.start] ?? "")?.[1];
2424
3794
  if (id && !ids.includes(id)) ids.push(id);
2425
3795
  }
2426
3796
  return ids;
@@ -2514,7 +3884,7 @@ const runLedgerValidate = async (inputs) => {
2514
3884
  });
2515
3885
  return result;
2516
3886
  }
2517
- const spec = parseTasksSpec(await readFile(join(inputs.repoRoot, tasksRel), "utf8"));
3887
+ const spec = parsedFrom(tasksRel, await readFile(join(inputs.repoRoot, tasksRel), "utf8"), parseTasksSpec);
2518
3888
  for (const index of spec.duplicateIndexes) problems.push({
2519
3889
  kind: "duplicate-group-index",
2520
3890
  message: `tasks.md declares "## Group ${index}" more than once. A step resolves to the first, so every later group of that number is never reviewed.`,
@@ -2524,14 +3894,32 @@ const runLedgerValidate = async (inputs) => {
2524
3894
  kind: "orphan-task",
2525
3895
  message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
2526
3896
  });
3897
+ for (const duplicate of spec.duplicateTaskIds) {
3898
+ const where = duplicate.groups.map((group) => group === null ? "no group" : `Group ${group}`).join(", ");
3899
+ problems.push({
3900
+ kind: "duplicate-task-id",
3901
+ message: `${duplicate.id} is written ${duplicate.groups.length} times in tasks.md (${where}). A task id names the task in the ledger's criteria (a task declaring no ref is its own criterion) and in every report, so the copies cannot be told apart and one can pass on the other's evidence. Give each copy its own id — the next free "T<n>" in tasks.md.`,
3902
+ subject: duplicate.id
3903
+ });
3904
+ }
2527
3905
  for (const header of spec.malformedRiskHeaders) problems.push({
2528
3906
  kind: "malformed-risk-marker",
2529
3907
  message: `"${header}" carries a risk token that is not a parseable "[risk: <class>]" marker at end of line. Left as-is it reads as no declared risk.`,
2530
3908
  subject: header
2531
3909
  });
3910
+ for (const malformed of spec.malformedGroupHeaders) problems.push({
3911
+ kind: "malformed-group-header",
3912
+ message: malformedGroupHeaderMessage(malformed),
3913
+ subject: malformed.header
3914
+ });
3915
+ for (const malformed of spec.malformedTaskLines) problems.push({
3916
+ kind: "malformed-task-line",
3917
+ message: malformedTaskLineMessage(malformed),
3918
+ subject: malformed.line
3919
+ });
2532
3920
  for (const id of spec.malformedTaskRefs) problems.push({
2533
3921
  kind: "malformed-task-refs",
2534
- message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
3922
+ message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. If the group is a prose aside rather than the list, open it with "per" ("(per R42, …)"): a bare aside falls under the same rules as the declaration, so only the real list may be declaration-shaped. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
2535
3923
  subject: id
2536
3924
  });
2537
3925
  const groups = resolveGroups(spec.groups, address, problems);
@@ -2572,6 +3960,23 @@ const runLedgerValidate = async (inputs) => {
2572
3960
  return result;
2573
3961
  };
2574
3962
  const reviewLedgerPath = (taskId, address) => join(TASKS_DIR, taskId, REVIEW_LEDGER_DIRNAME, address.kind === "full-pass" ? FULL_PASS_FILENAME : `step-${address.step}.json`);
3963
+ const malformedGroupHeaderMessage = (malformed) => {
3964
+ switch (malformed.defect) {
3965
+ case "zero": return `"${malformed.header}" — group numbers start at 1 and no --step addresses 0, so the tasks under it are read into no group. Renumber the groups from 1.`;
3966
+ case "lookalike": return `"${malformed.header}" looks like a group header but is not a top-level "## Group <n>" (two # at the start of the line, "Group" capitalized and in English whatever the spec's language, a bare number, outside any list or quote), so the tasks under it are read into no group — not even the one above. Write it as "## Group <n> — …" at the start of a line of its own; a line underlined with "---" or "===" is a heading too, so put a blank line above a rule that is not one; if it is not a group, reword it so it no longer reads "Group <word>", or "<word> <n>" with a "_(…)_" or "*(…)*" rationale or a "[risk: …]" tag.`;
3967
+ case "fenced": return `"${malformed.header}" is a group header inside a code block (a fence, four-space-indented code, raw HTML) — a quoted sample and a real header under a fence that never closed cannot be told apart, so it is not read as a group. If it is a sample, reword it so it no longer reads "Group <n>", or "<word> <n>" with a "_(…)_" or "*(…)*" rationale or a "[risk: …]" tag; if it is a group, close the fence above it or remove the indentation, and write it as "## Group <n> — …".`;
3968
+ }
3969
+ };
3970
+ const malformedTaskLineMessage = (malformed) => {
3971
+ const left = "so it is not read as a task — it leaves its group's slice and tick check, and its refs with it";
3972
+ switch (malformed.defect) {
3973
+ case "checkbox": return `"${malformed.line}" has a checkbox that is neither "[ ]" nor "[x]" (an in-progress "[-]", a padded "[ ]"), ${left}. Write "[ ]", or "[x]" once the task is green.`;
3974
+ case "id": return `"${malformed.line}" has a task id that is not a bare "T<n>" (a suffixed "T12b", a sub-numbered "T1.1"), ${left}. Give it the next free "T<n>" in tasks.md instead.`;
3975
+ case "shape": return `"${malformed.line}" is not written as a task line "- [ ] T<n>" (a bullet with no checkbox, a numbered "1. [ ]" item, an id in single "*" or "_", a task inside a ">" quote), ${left}. Write it "- [ ] T<n> — …" outside any quote (a bold "**T<n> — …**" title is fine); if it is a note, not a task, reword it so it does not open with "T<n>".`;
3976
+ case "nested": return `"${malformed.line}" is a task line nested inside ${malformed.parentId}'s list item — a sub-step of it and a task of its own cannot be told apart, ${left}. Outdent it to the task list if it is a task; drop its "T<n>" id if it is a sub-step.`;
3977
+ case "fenced": return `"${malformed.line}" is a task line inside a code block (a fence, four-space-indented code, raw HTML) — a quoted sample and a task under a fence that never closed cannot be told apart, ${left}. If it is a sample, drop its "T<n>" id; if it is a task, close the fence above it or remove the indentation.`;
3978
+ }
3979
+ };
2575
3980
  const resolveGroups = (groups, address, problems) => {
2576
3981
  if (address.kind === "full-pass") {
2577
3982
  if (groups.length > 0) return groups;
@@ -2609,7 +4014,7 @@ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2609
4014
  });
2610
4015
  return referenced;
2611
4016
  }
2612
- const declared = parseRequirementIds(await readFile(requirementsPath, "utf8"));
4017
+ const declared = parsedFrom(requirementsPath, await readFile(requirementsPath, "utf8"), parseRequirementIds);
2613
4018
  const dangling = referenced.filter((id) => !declared.includes(id));
2614
4019
  for (const id of dangling) problems.push({
2615
4020
  kind: "dangling-requirement-ref",
@@ -2710,19 +4115,7 @@ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
2710
4115
  });
2711
4116
  }
2712
4117
  };
2713
- const collectDeclaredGates = async (repoRoot) => {
2714
- if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
2715
- return (await readHarnessConfig(repoRoot)).gates;
2716
- };
2717
- const configEntryExists = async (path) => {
2718
- try {
2719
- await lstat(path);
2720
- return true;
2721
- } catch (error) {
2722
- if (error.code === "ENOENT") return false;
2723
- throw error;
2724
- }
2725
- };
4118
+ const collectDeclaredGates = async (repoRoot) => (await readOptionalHarnessConfig(repoRoot))?.gates;
2726
4119
  const owedGates = (declared) => ({
2727
4120
  basis: declared === void 0 ? "undeclared" : "config",
2728
4121
  required: [...new Set(declared ?? [])],
@@ -2782,6 +4175,13 @@ const checkCriteria = (required, dangling, ledger, problems) => {
2782
4175
  present
2783
4176
  };
2784
4177
  };
4178
+ const parsedFrom = (path, text, parse) => {
4179
+ try {
4180
+ return parse(text);
4181
+ } catch (error) {
4182
+ throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
4183
+ }
4184
+ };
2785
4185
  //#endregion
2786
4186
  //#region src/spinoff/spinoff.constant.ts
2787
4187
  const MANAGED_LABEL = "harness:managed";
@@ -2917,7 +4317,8 @@ const FIELD_AXIS = {
2917
4317
  cycle_time_h: "metric",
2918
4318
  review_rejections: "metric",
2919
4319
  mode: "internal-enum",
2920
- steps: "metric"
4320
+ steps: "metric",
4321
+ checkpoints: "metric"
2921
4322
  },
2922
4323
  review_rejected: {
2923
4324
  ...ENVELOPE_AXIS,
@@ -3549,7 +4950,7 @@ const inspectDevDependency = async (repoRoot) => {
3549
4950
  const pkgPath = join(repoRoot, "package.json");
3550
4951
  if (!await pathExists(pkgPath)) return "no-package-json";
3551
4952
  try {
3552
- const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
4953
+ const pkg = JSON.parse(await readRegularFile(pkgPath));
3553
4954
  return Object.hasOwn(pkg.dependencies ?? {}, "@lemoncode/lemony") || Object.hasOwn(pkg.devDependencies ?? {}, "@lemoncode/lemony") ? "declared" : "missing";
3554
4955
  } catch {
3555
4956
  return "no-package-json";
@@ -3708,93 +5109,390 @@ const renderRoleSkills = (selected, role) => {
3708
5109
  return groups.length > 0 ? groups.join("\n\n") : "_None._";
3709
5110
  };
3710
5111
  //#endregion
3711
- //#region src/doctor/cli-bin.constant.ts
3712
- const LEMONY_BIN = "lemony";
5112
+ //#region src/merge/conflict-markers.constant.ts
5113
+ const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
5114
+ const CONFLICT_MARKER_SEPARATOR = "=======";
5115
+ const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
3713
5116
  //#endregion
3714
- //#region src/doctor/resolve-cli.ts
3715
- const isExecutable = async (path) => {
3716
- try {
3717
- await access(path, constants.X_OK);
3718
- return true;
3719
- } catch {
3720
- return false;
3721
- }
3722
- };
3723
- const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
3724
- if (!pathEnv) return false;
3725
- for (const dir of pathEnv.split(delimiter)) {
3726
- if (dir === "") continue;
3727
- if (await isExe(join(dir, "lemony"))) return true;
3728
- }
3729
- return false;
3730
- };
5117
+ //#region src/merge/conflict-markers.ts
5118
+ const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
5119
+ const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
3731
5120
  //#endregion
3732
- //#region src/doctor/doctor.ts
3733
- const VENDOR_HOOK_COMMANDS = [
3734
- ".claude/hooks/init.sh",
3735
- ".claude/hooks/session-close.sh",
3736
- ".claude/hooks/require-playbook.sh",
3737
- ".claude/hooks/suggest-playbook.sh"
3738
- ];
3739
- const runDoctor = async (deps) => {
3740
- const { repoRoot } = deps;
3741
- const checks = [];
3742
- let config = null;
3743
- try {
3744
- config = await readHarnessConfig(repoRoot);
3745
- checks.push({
3746
- name: "config",
3747
- status: "ok",
3748
- detail: `${HARNESS_CONFIG_FILENAME} is valid.`
3749
- });
3750
- } catch (error) {
3751
- checks.push({
3752
- name: "config",
3753
- status: "fail",
3754
- detail: error.message,
3755
- remediation: `Fix ${HARNESS_CONFIG_FILENAME} (the error above names the key), then re-run \`lemony doctor\`.`
3756
- });
5121
+ //#region src/merge/three-way-merge.ts
5122
+ const threeWayMerge = (base, client, vendor) => {
5123
+ const baseLines = base.split("\n");
5124
+ const clientLines = client.split("\n");
5125
+ const vendorLines = vendor.split("\n");
5126
+ const regions = diff3Regions(baseLines, clientLines, vendorLines);
5127
+ const out = [];
5128
+ let conflicted = false;
5129
+ for (const region of regions) {
5130
+ if (region.stable) {
5131
+ out.push(...region.lines);
5132
+ continue;
5133
+ }
5134
+ if (linesEqual(region.client, region.vendor)) out.push(...region.client);
5135
+ else {
5136
+ conflicted = true;
5137
+ out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
5138
+ }
3757
5139
  }
3758
- checks.push(await checkLabels(deps, config));
3759
- checks.push(await checkHooks(repoRoot));
3760
- checks.push(await checkGitUser(deps));
3761
- checks.push(await checkGhAuth(deps, config));
3762
- checks.push(checkVersionPin(deps, config));
3763
- checks.push(await checkCliResolution(deps));
3764
- checks.push(await checkCapabilities(deps, config));
3765
- checks.push(await checkDesignToolDrift(deps));
3766
- checks.push(await checkHookLib(deps));
3767
5140
  return {
3768
- checks,
3769
- ok: checks.every((check) => check.status !== "fail")
5141
+ merged: out.join("\n"),
5142
+ conflicted
3770
5143
  };
3771
5144
  };
3772
- const checkLabels = async (deps, config) => {
3773
- const name = "labels";
3774
- if (!config) return {
3775
- name,
3776
- status: "warn",
3777
- detail: "Skipped — config did not validate."
3778
- };
3779
- const slug = config.task_storage.repo;
3780
- if (slug === "OWNER/REPO") return {
3781
- name,
3782
- status: "warn",
3783
- detail: `task_storage.repo is the ${TASK_STORAGE_REPO_PLACEHOLDER} placeholder — set a real slug to manage labels.`,
3784
- remediation: `Set task_storage.repo in ${HARNESS_CONFIG_FILENAME}, then run \`lemony repair\`.`
3785
- };
3786
- const list = await createTaskTrackerProvider(config.task_storage.type, deps.runCommand).listLabels(slug);
3787
- if (list.code !== 0) return {
3788
- name,
3789
- status: "warn",
3790
- detail: `Could not list labels via \`gh\` (exit ${list.code}). Is gh installed and authenticated?`
5145
+ const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
5146
+ const diff3Regions = (base, client, vendor) => {
5147
+ const hunks = [...diffRegions(base, client).map((d) => ({
5148
+ ...d,
5149
+ side: "client"
5150
+ })), ...diffRegions(base, vendor).map((d) => ({
5151
+ ...d,
5152
+ side: "vendor"
5153
+ }))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
5154
+ const regions = [];
5155
+ let baseCursor = 0;
5156
+ let i = 0;
5157
+ const emitStable = (until) => {
5158
+ if (until > baseCursor) {
5159
+ regions.push({
5160
+ stable: true,
5161
+ lines: base.slice(baseCursor, until)
5162
+ });
5163
+ baseCursor = until;
5164
+ }
3791
5165
  };
3792
- let existing;
3793
- try {
3794
- existing = JSON.parse(list.stdout || "[]");
3795
- } catch {
3796
- return {
3797
- name,
5166
+ while (i < hunks.length) {
5167
+ const first = hunks[i];
5168
+ if (first === void 0) break;
5169
+ let regionBaseStart = first.baseStart;
5170
+ let regionBaseEnd = first.baseEnd;
5171
+ const group = [first];
5172
+ i += 1;
5173
+ while (i < hunks.length) {
5174
+ const next = hunks[i];
5175
+ if (next === void 0 || next.baseStart > regionBaseEnd) break;
5176
+ regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
5177
+ group.push(next);
5178
+ i += 1;
5179
+ }
5180
+ emitStable(regionBaseStart);
5181
+ const touchedByClient = group.some((h) => h.side === "client");
5182
+ const touchedByVendor = group.some((h) => h.side === "vendor");
5183
+ if (touchedByClient && touchedByVendor) regions.push({
5184
+ stable: false,
5185
+ client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
5186
+ vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
5187
+ });
5188
+ else {
5189
+ const side = touchedByClient ? "client" : "vendor";
5190
+ regions.push({
5191
+ stable: true,
5192
+ lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
5193
+ });
5194
+ }
5195
+ baseCursor = regionBaseEnd;
5196
+ }
5197
+ emitStable(base.length);
5198
+ return regions;
5199
+ };
5200
+ const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
5201
+ const own = group.filter((h) => h.side === side);
5202
+ let sideStart = Number.POSITIVE_INFINITY;
5203
+ let sideEnd = Number.NEGATIVE_INFINITY;
5204
+ let baseStart = Number.POSITIVE_INFINITY;
5205
+ let baseEnd = Number.NEGATIVE_INFINITY;
5206
+ for (const h of own) {
5207
+ sideStart = Math.min(sideStart, h.sideStart);
5208
+ sideEnd = Math.max(sideEnd, h.sideEnd);
5209
+ baseStart = Math.min(baseStart, h.baseStart);
5210
+ baseEnd = Math.max(baseEnd, h.baseEnd);
5211
+ }
5212
+ const start = sideStart - (baseStart - regionBaseStart);
5213
+ const end = sideEnd + (regionBaseEnd - baseEnd);
5214
+ return sideLines.slice(start, end);
5215
+ };
5216
+ const diffRegions = (base, other) => {
5217
+ const matches = lcsMatches(base, other);
5218
+ const diffs = [];
5219
+ let baseTail = 0;
5220
+ let otherTail = 0;
5221
+ for (const [baseIdx, otherIdx] of matches) {
5222
+ if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
5223
+ baseStart: baseTail,
5224
+ baseEnd: baseIdx,
5225
+ sideStart: otherTail,
5226
+ sideEnd: otherIdx
5227
+ });
5228
+ baseTail = baseIdx + 1;
5229
+ otherTail = otherIdx + 1;
5230
+ }
5231
+ if (base.length > baseTail || other.length > otherTail) diffs.push({
5232
+ baseStart: baseTail,
5233
+ baseEnd: base.length,
5234
+ sideStart: otherTail,
5235
+ sideEnd: other.length
5236
+ });
5237
+ return diffs;
5238
+ };
5239
+ const lcsMatches = (base, other) => {
5240
+ const n = base.length;
5241
+ const m = other.length;
5242
+ const width = m + 1;
5243
+ const dp = new Int32Array((n + 1) * width);
5244
+ const at = (i, j) => dp[i * width + j] ?? 0;
5245
+ for (let i = n - 1; i >= 0; i -= 1) for (let j = m - 1; j >= 0; j -= 1) dp[i * width + j] = base[i] === other[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
5246
+ const matches = [];
5247
+ let i = 0;
5248
+ let j = 0;
5249
+ while (i < n && j < m) if (base[i] === other[j]) {
5250
+ matches.push([i, j]);
5251
+ i += 1;
5252
+ j += 1;
5253
+ } else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
5254
+ else j += 1;
5255
+ return matches;
5256
+ };
5257
+ //#endregion
5258
+ //#region src/doctor/agent-frontmatter.ts
5259
+ const CLAUDE_CODE_FRONTMATTER = /^---\s*\n([\s\S]*?)---\s*\n?/;
5260
+ const PLAIN_KEY_LINE = /^(?![-?:](?:[ \t]|$))([^\s#'"{}[\],&*!|>%@`].*?):(?:[ \t]|$)/;
5261
+ const DOUBLE_QUOTED_KEY_LINE = /^"((?:[^"\\]|\\.)*)"[ \t]*:(?:[ \t]|$)/;
5262
+ const SINGLE_QUOTED_KEY_LINE = /^'((?:[^']|'')*)'[ \t]*:(?:[ \t]|$)/;
5263
+ const topLevelKey = (line) => {
5264
+ const double = DOUBLE_QUOTED_KEY_LINE.exec(line)?.[1];
5265
+ if (double !== void 0) try {
5266
+ return JSON.parse(`"${double}"`);
5267
+ } catch {
5268
+ return double;
5269
+ }
5270
+ const single = SINGLE_QUOTED_KEY_LINE.exec(line)?.[1];
5271
+ if (single !== void 0) return single.replaceAll("''", "'");
5272
+ return PLAIN_KEY_LINE.exec(line)?.[1]?.replace(/[ \t]+$/, "") ?? null;
5273
+ };
5274
+ const inspectAgentFrontmatter = (content) => {
5275
+ if (hasConflictMarkers(content)) return { kind: "conflict-markers" };
5276
+ const block = CLAUDE_CODE_FRONTMATTER.exec(content.replace(/^\uFEFF/, ""))?.[1];
5277
+ if (block === void 0) return null;
5278
+ const seen = /* @__PURE__ */ new Set();
5279
+ const duplicated = /* @__PURE__ */ new Set();
5280
+ for (const line of block.split(/\r?\n/)) {
5281
+ const key = topLevelKey(line);
5282
+ if (key === null) continue;
5283
+ if (seen.has(key)) duplicated.add(key);
5284
+ seen.add(key);
5285
+ }
5286
+ return duplicated.size > 0 ? {
5287
+ kind: "duplicate-keys",
5288
+ keys: [...duplicated]
5289
+ } : null;
5290
+ };
5291
+ //#endregion
5292
+ //#region src/doctor/cli-bin.constant.ts
5293
+ const LEMONY_BIN = "lemony";
5294
+ //#endregion
5295
+ //#region src/doctor/hook-lib.constant.ts
5296
+ const HOOK_LIB_CONSEQUENCES = {
5297
+ "lemony.sh": {
5298
+ consequence: "agent and hook calls through the `lemony` CLI launcher fail (event emits, spinoff, discovery resume)",
5299
+ sourced: false
5300
+ },
5301
+ "live-branch.sh": {
5302
+ consequence: "init.sh and session-close.sh read the task branch from HEAD only (a rebase in progress reads as no branch)",
5303
+ sourced: true
5304
+ },
5305
+ "merge-pr.sh": {
5306
+ consequence: "agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime",
5307
+ sourced: false
5308
+ },
5309
+ "playbook-scan.sh": {
5310
+ consequence: "require-playbook.sh and suggest-playbook.sh error out, so playbook enforcement is off",
5311
+ sourced: true
5312
+ },
5313
+ "transcript-grep.sh": {
5314
+ consequence: "require-playbook.sh blocks matching writes even after the playbook was read",
5315
+ sourced: true
5316
+ }
5317
+ };
5318
+ const HOOK_LIB_UNKNOWN_CONSEQUENCE = "the hooks and agents that use it fail at runtime";
5319
+ const HOOK_LIB_SOURCED_EXEC_BIT_CONSEQUENCE = "the hooks `source` it, which needs only the read bit, so nothing breaks yet";
5320
+ //#endregion
5321
+ //#region src/doctor/resolve-cli.ts
5322
+ const isExecutable = async (path) => {
5323
+ try {
5324
+ await access(path, constants.X_OK);
5325
+ return true;
5326
+ } catch {
5327
+ return false;
5328
+ }
5329
+ };
5330
+ const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
5331
+ if (!pathEnv) return false;
5332
+ for (const dir of pathEnv.split(delimiter)) {
5333
+ if (dir === "") continue;
5334
+ if (await isExe(join(dir, "lemony"))) return true;
5335
+ }
5336
+ return false;
5337
+ };
5338
+ //#endregion
5339
+ //#region src/doctor/doctor.ts
5340
+ const VENDOR_HOOK_COMMANDS = [
5341
+ ".claude/hooks/init.sh",
5342
+ ".claude/hooks/session-close.sh",
5343
+ ".claude/hooks/require-playbook.sh",
5344
+ ".claude/hooks/suggest-playbook.sh"
5345
+ ];
5346
+ const runDoctor = async (deps) => {
5347
+ const { repoRoot } = deps;
5348
+ const checks = [];
5349
+ let config = null;
5350
+ let configSkip = "";
5351
+ let configState = "loaded";
5352
+ try {
5353
+ config = await readHarnessConfig(repoRoot);
5354
+ checks.push({
5355
+ name: "config",
5356
+ status: "ok",
5357
+ detail: `${HARNESS_CONFIG_FILENAME} is valid.`
5358
+ });
5359
+ } catch (error) {
5360
+ const fault = await configLoadFault(repoRoot, error);
5361
+ configSkip = fault.skip;
5362
+ configState = fault.state;
5363
+ checks.push({
5364
+ name: "config",
5365
+ status: "fail",
5366
+ detail: error.message,
5367
+ remediation: fault.remediation
5368
+ });
5369
+ }
5370
+ checks.push(await checkLabels(deps, config, configSkip));
5371
+ const restore = await planRestore(deps, configState);
5372
+ const configCheck = checks.find((check) => check.name === "config");
5373
+ if (configState === "absent" && configCheck !== void 0) configCheck.remediation = `${capitalize(verbStep(restore))}, then re-run \`lemony doctor\`.`;
5374
+ checks.push(await checkHooks(repoRoot, restore));
5375
+ checks.push(await checkGitUser(deps));
5376
+ checks.push(await checkGhAuth(deps, config));
5377
+ checks.push(checkVersionPin(deps, config, configSkip));
5378
+ checks.push(await checkCliResolution(deps));
5379
+ checks.push(await checkCapabilities(deps, config, configSkip));
5380
+ checks.push(await checkDesignToolDrift(deps, config, configSkip));
5381
+ checks.push(await checkHookLib(deps, restore));
5382
+ checks.push(await checkAgentFrontmatter(repoRoot));
5383
+ return {
5384
+ checks,
5385
+ ok: checks.every((check) => check.status !== "fail")
5386
+ };
5387
+ };
5388
+ const configLoadFault = async (repoRoot, loadError) => {
5389
+ const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
5390
+ const readError = await readRegularFile(configPath).then(() => void 0, (error) => error);
5391
+ switch (await classifyConfigLinkFault(configPath, readError)) {
5392
+ case "target-missing": return {
5393
+ skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink whose target is missing.`,
5394
+ remediation: "Fix the symlink as the error above says, then re-run `lemony doctor`.",
5395
+ state: "broken"
5396
+ };
5397
+ case "loop": return {
5398
+ skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink that loops or runs through too many links.`,
5399
+ remediation: "Fix the symlink as the error above says, then re-run `lemony doctor`.",
5400
+ state: "broken"
5401
+ };
5402
+ case "target-unreadable": return {
5403
+ skip: `Skipped — ${HARNESS_CONFIG_FILENAME} is a symlink whose target cannot be read.`,
5404
+ remediation: "Fix the permissions or the symlink as the error above says, then re-run `lemony doctor`.",
5405
+ state: "broken"
5406
+ };
5407
+ case void 0:
5408
+ }
5409
+ if (readError?.code === "ENOENT") {
5410
+ if (await hasInstallBaseline(repoRoot)) return {
5411
+ skip: `Skipped — no ${HARNESS_CONFIG_FILENAME}.`,
5412
+ remediation: "Do what the error above says for your case, then re-run `lemony doctor`.",
5413
+ state: "broken"
5414
+ };
5415
+ return {
5416
+ skip: `Skipped — no ${HARNESS_CONFIG_FILENAME}.`,
5417
+ remediation: "Run `lemony install`, then re-run `lemony doctor`.",
5418
+ state: "absent"
5419
+ };
5420
+ }
5421
+ if (readError !== void 0) return {
5422
+ skip: `Skipped — ${HARNESS_CONFIG_FILENAME} could not be read.`,
5423
+ remediation: "Fix what the error above names, then re-run `lemony doctor`.",
5424
+ state: "broken"
5425
+ };
5426
+ if (loadError.message === CONFIG_MALFORMED_MESSAGE) return {
5427
+ skip: "Skipped — config did not validate.",
5428
+ remediation: `Make ${HARNESS_CONFIG_FILENAME} a YAML mapping of config keys (fix the syntax, or restore its content), then re-run \`lemony doctor\`.`,
5429
+ state: "broken"
5430
+ };
5431
+ return {
5432
+ skip: "Skipped — config did not validate.",
5433
+ remediation: `Fix ${HARNESS_CONFIG_FILENAME} (the error above names the key), then re-run \`lemony doctor\`.`,
5434
+ state: "broken"
5435
+ };
5436
+ };
5437
+ const FIX_CONFIG_FIRST = `Fix ${HARNESS_CONFIG_FILENAME} first (the config check says how), then re-run \`lemony doctor\``;
5438
+ const restoreVerb = (state) => state === "absent" ? "install" : "repair";
5439
+ const planRestore = async (deps, state) => {
5440
+ const helpers = await listFiles(join(deps.vendorRoot, "hooks", "lib")).then((files) => files.filter((rel) => rel.endsWith(".sh")), () => []);
5441
+ const leaves = [
5442
+ SETTINGS_FILE,
5443
+ ".gitignore",
5444
+ ...helpers.map((helper) => join(HOOKS_DIR, "lib", helper))
5445
+ ];
5446
+ const links = await findSymlinkTraversals(deps.repoRoot, leaves);
5447
+ const copyable = await Promise.all(links.map((rel) => stat(join(deps.repoRoot, rel)).then((target) => !leaves.includes(rel) || target.isFile(), (error) => !NOWHERE_CODES.has(error.code ?? ""))));
5448
+ return {
5449
+ state,
5450
+ linked: links.filter((_, index) => copyable[index]),
5451
+ dangling: links.filter((_, index) => !copyable[index])
5452
+ };
5453
+ };
5454
+ const NOWHERE_CODES = /* @__PURE__ */ new Set([
5455
+ "ENOENT",
5456
+ "ENOTDIR",
5457
+ "ELOOP"
5458
+ ]);
5459
+ const verbStep = (restore, removed = []) => {
5460
+ const verb = restoreVerb(restore.state);
5461
+ const kept = (rels) => rels.filter((rel) => !removed.includes(rel));
5462
+ const linked = kept(restore.linked);
5463
+ const dangling = kept(restore.dangling);
5464
+ const steps = [];
5465
+ if (dangling.length > 0) steps.push(dangling.length === 1 ? `remove the symlink ${dangling[0]}, which leads to no file` : `remove the symlinks ${dangling.join(" ")}, which lead to no file`);
5466
+ if (linked.length > 0) steps.push(linked.length === 1 ? `replace the symlink ${linked[0]} with a regular copy of its target` : `replace the symlinks ${linked.join(" ")} with regular copies of their targets`);
5467
+ if (steps.length === 0) return `run \`lemony ${verb}\``;
5468
+ return `${steps.join(", ")} (\`${verb}\` refuses to write through a symlink), then run \`lemony ${verb}\``;
5469
+ };
5470
+ const checkLabels = async (deps, config, configSkip) => {
5471
+ const name = "labels";
5472
+ if (!config) return {
5473
+ name,
5474
+ status: "warn",
5475
+ detail: configSkip
5476
+ };
5477
+ const slug = config.task_storage.repo;
5478
+ if (slug === "OWNER/REPO") return {
5479
+ name,
5480
+ status: "warn",
5481
+ detail: `task_storage.repo is the ${TASK_STORAGE_REPO_PLACEHOLDER} placeholder — set a real slug to manage labels.`,
5482
+ remediation: `Set task_storage.repo in ${HARNESS_CONFIG_FILENAME}, then run \`lemony repair\`.`
5483
+ };
5484
+ const list = await createTaskTrackerProvider(config.task_storage.type, deps.runCommand).listLabels(slug);
5485
+ if (list.code !== 0) return {
5486
+ name,
5487
+ status: "warn",
5488
+ detail: `Could not list labels via \`gh\` (exit ${list.code}). Is gh installed and authenticated?`
5489
+ };
5490
+ let existing;
5491
+ try {
5492
+ existing = JSON.parse(list.stdout || "[]");
5493
+ } catch {
5494
+ return {
5495
+ name,
3798
5496
  status: "warn",
3799
5497
  detail: "Could not parse `gh label list` output."
3800
5498
  };
@@ -3815,18 +5513,32 @@ const checkLabels = async (deps, config) => {
3815
5513
  remediation: "Run `lemony repair` to reconcile the labels."
3816
5514
  };
3817
5515
  };
3818
- const checkHooks = async (repoRoot) => {
5516
+ const checkHooks = async (repoRoot, restore) => {
3819
5517
  const name = "hooks";
5518
+ const remedy = (steps) => restore.state === "broken" ? `${FIX_CONFIG_FIRST}.` : `${capitalize(steps)}.`;
3820
5519
  const settingsPath = join(repoRoot, SETTINGS_FILE);
3821
5520
  let raw;
3822
5521
  try {
3823
- raw = await readFile(settingsPath, "utf8");
3824
- } catch {
3825
- return {
5522
+ raw = await readRegularFile(settingsPath);
5523
+ } catch (error) {
5524
+ const code = error.code;
5525
+ if (code === "ENOENT" && await lstat(settingsPath).then(() => true, () => false)) return {
5526
+ name,
5527
+ status: "fail",
5528
+ detail: ".claude/settings.json is a symlink whose target is missing.",
5529
+ remediation: remedy(`remove the symlink, then ${verbStep(restore, [SETTINGS_FILE])}`)
5530
+ };
5531
+ if (code === "ENOENT") return {
3826
5532
  name,
3827
5533
  status: "fail",
3828
5534
  detail: ".claude/settings.json not found.",
3829
- remediation: "Run `lemony repair`."
5535
+ remediation: remedy(verbStep(restore))
5536
+ };
5537
+ return {
5538
+ name,
5539
+ status: "fail",
5540
+ detail: `Cannot read .claude/settings.json: ${code === void 0 ? error.message : `${code}.`}`,
5541
+ remediation: remedy(`replace it with a regular, readable file (or remove it), then ${verbStep(restore, [SETTINGS_FILE])}`)
3830
5542
  };
3831
5543
  }
3832
5544
  let commands;
@@ -3838,7 +5550,7 @@ const checkHooks = async (repoRoot) => {
3838
5550
  name,
3839
5551
  status: "fail",
3840
5552
  detail: ".claude/settings.json is not valid JSON.",
3841
- remediation: "Run `lemony repair`."
5553
+ remediation: remedy(`fix the JSON or delete the file, then ${verbStep(restore)}`)
3842
5554
  };
3843
5555
  }
3844
5556
  const missing = VENDOR_HOOK_COMMANDS.filter((want) => !commands.some((cmd) => cmd === want || cmd.endsWith(`/${want}`)));
@@ -3851,7 +5563,7 @@ const checkHooks = async (repoRoot) => {
3851
5563
  name,
3852
5564
  status: "warn",
3853
5565
  detail: `Missing vendor hook wiring: ${missing.join(", ")}.`,
3854
- remediation: "Run `lemony repair`."
5566
+ remediation: remedy(verbStep(restore))
3855
5567
  };
3856
5568
  };
3857
5569
  const checkGitUser = async (deps) => {
@@ -3888,12 +5600,12 @@ const checkGhAuth = async (deps, config) => {
3888
5600
  remediation: "Run `gh auth login`."
3889
5601
  };
3890
5602
  };
3891
- const checkVersionPin = (deps, config) => {
5603
+ const checkVersionPin = (deps, config, configSkip) => {
3892
5604
  const name = "version-pin";
3893
5605
  if (!config) return {
3894
5606
  name,
3895
5607
  status: "warn",
3896
- detail: "Skipped — config did not validate."
5608
+ detail: configSkip
3897
5609
  };
3898
5610
  if (config.vendor_version === deps.installedVersion) return {
3899
5611
  name,
@@ -3950,12 +5662,12 @@ const checkCliResolution = async (deps) => {
3950
5662
  remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
3951
5663
  };
3952
5664
  };
3953
- const checkCapabilities = async (deps, config) => {
5665
+ const checkCapabilities = async (deps, config, configSkip) => {
3954
5666
  const name = "capabilities";
3955
5667
  if (!config) return {
3956
5668
  name,
3957
5669
  status: "warn",
3958
- detail: "Skipped — config did not validate."
5670
+ detail: configSkip
3959
5671
  };
3960
5672
  let latent;
3961
5673
  try {
@@ -3982,29 +5694,52 @@ const checkCapabilities = async (deps, config) => {
3982
5694
  remediation: "Run /add-capability in Claude Code to activate one — the Architect authors the artifact, then `repair` installs the skill (opt-in, never imposed)."
3983
5695
  };
3984
5696
  };
3985
- const checkDesignToolDrift = async (deps) => {
5697
+ const checkDesignToolDrift = async (deps, config, configSkip) => {
3986
5698
  const name = "design-tool-drift";
3987
- const tokenPath = join(deps.repoRoot, DESIGN_TOKENS_FILE);
3988
- if (!await pathExists(tokenPath)) return {
5699
+ if (!config) return {
5700
+ name,
5701
+ status: "warn",
5702
+ detail: configSkip
5703
+ };
5704
+ const tokenFile = resolveTokenFile(config.design_tokens.file);
5705
+ let raw;
5706
+ try {
5707
+ raw = await readOptionalTokenFile(deps.repoRoot, tokenFile);
5708
+ } catch (error) {
5709
+ return {
5710
+ name,
5711
+ status: "fail",
5712
+ detail: error.message,
5713
+ remediation: "Run `lemony design-tokens validate` to find the problem."
5714
+ };
5715
+ }
5716
+ if (raw === void 0) return {
3989
5717
  name,
3990
5718
  status: "info",
3991
- detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
5719
+ detail: `No ${tokenFile} — token sync not in use.`
3992
5720
  };
3993
5721
  let parsed;
3994
5722
  try {
3995
- parsed = JSON.parse(await readFile(tokenPath, "utf8"));
5723
+ parsed = JSON.parse(raw);
3996
5724
  } catch (error) {
3997
5725
  return {
3998
5726
  name,
3999
- status: "warn",
4000
- detail: `Could not read ${DESIGN_TOKENS_FILE}: ${error.message}`,
5727
+ status: "fail",
5728
+ detail: `${tokenFile} is not valid JSON: ${error.message}`,
4001
5729
  remediation: "Run `lemony design-tokens validate` to find the problem."
4002
5730
  };
4003
5731
  }
4004
5732
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
4005
5733
  name,
4006
- status: "warn",
4007
- detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
5734
+ status: "fail",
5735
+ detail: `${tokenFile} is not a JSON object.`,
5736
+ remediation: "Run `lemony design-tokens validate` to find the problem."
5737
+ };
5738
+ const invalid = summarizeTokenFileViolations(raw, tokenFile);
5739
+ if (invalid !== void 0) return {
5740
+ name,
5741
+ status: "fail",
5742
+ detail: `${tokenFile} fails validation: ${invalid}`,
4008
5743
  remediation: "Run `lemony design-tokens validate` to find the problem."
4009
5744
  };
4010
5745
  const report = driftReport(parsed);
@@ -4033,7 +5768,7 @@ const checkDesignToolDrift = async (deps) => {
4033
5768
  };
4034
5769
  }
4035
5770
  };
4036
- const checkHookLib = async (deps) => {
5771
+ const checkHookLib = async (deps, restore) => {
4037
5772
  const name = "hook-lib";
4038
5773
  let helpers;
4039
5774
  try {
@@ -4083,7 +5818,8 @@ const checkHookLib = async (deps) => {
4083
5818
  if (!await isExecutable(installed)) return {
4084
5819
  helper,
4085
5820
  kind: "mode",
4086
- description: `${helper} is not executable`
5821
+ description: `${helper} is not executable`,
5822
+ execBitOnly: await isReadable(installed)
4087
5823
  };
4088
5824
  if (!await isReadable(installed)) return {
4089
5825
  helper,
@@ -4099,15 +5835,104 @@ const checkHookLib = async (deps) => {
4099
5835
  };
4100
5836
  const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
4101
5837
  const remediations = [];
4102
- if (problems.some((problem) => problem.kind === "missing")) remediations.push("Run `lemony repair` to restore the missing helpers");
4103
- if (problems.some((problem) => problem.kind === "obstructed")) remediations.push(`remove ${pathsOf("obstructed")} (\`repair\` cannot fix a non-file entry), then run \`lemony repair\``);
5838
+ const verb = restoreVerb(restore.state);
5839
+ const missing = problems.some((problem) => problem.kind === "missing");
5840
+ const obstructed = problems.some((problem) => problem.kind === "obstructed");
5841
+ const obstructedPaths = problems.filter((problem) => problem.kind === "obstructed").map((problem) => join(HOOKS_DIR, "lib", problem.helper));
5842
+ if ((missing || obstructed) && restore.state === "broken") remediations.push(FIX_CONFIG_FIRST);
5843
+ else if (obstructed) remediations.push(`remove ${pathsOf("obstructed")} (\`${verb}\` cannot fix a non-file entry), then ${verbStep(restore, obstructedPaths)}${missing ? " to restore them and the missing helpers" : ""}`);
5844
+ else if (missing) remediations.push(`${verbStep(restore)} to restore the missing helpers`);
4104
5845
  if (problems.some((problem) => problem.kind === "mode")) remediations.push(`run \`chmod +rx ${pathsOf("mode")}\` to restore the read/exec bits (\`repair\` only rewrites changed content, so it cannot)`);
4105
5846
  if (problems.some((problem) => problem.kind === "unreadable")) remediations.push(`fix permissions so ${HOOKS_DIR}/lib and its entries are readable, then re-run \`lemony doctor\``);
4106
5847
  return {
4107
5848
  name,
4108
5849
  status: "warn",
4109
- detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description).join("; ")} — agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime.`,
4110
- remediation: `${remediations.join("; ")}.`
5850
+ detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => `${problem.description} — ${hookLibConsequence(problem.helper, "execBitOnly" in problem && problem.execBitOnly === true)}`).join("; ")}.`,
5851
+ remediation: `${capitalize(remediations.join("; "))}.`
5852
+ };
5853
+ };
5854
+ const hookLibConsequence = (helper, execBitOnly) => {
5855
+ const entry = HOOK_LIB_CONSEQUENCES[helper];
5856
+ if (entry === void 0) return HOOK_LIB_UNKNOWN_CONSEQUENCE;
5857
+ return entry.sourced && execBitOnly ? HOOK_LIB_SOURCED_EXEC_BIT_CONSEQUENCE : entry.consequence;
5858
+ };
5859
+ const checkAgentFrontmatter = async (repoRoot) => {
5860
+ const name = "agent-frontmatter";
5861
+ let files;
5862
+ try {
5863
+ files = (await readdir(join(repoRoot, AGENTS_DIR), { withFileTypes: true })).filter((entry) => !entry.isDirectory() && entry.name.endsWith(".md")).map((entry) => entry.name).toSorted();
5864
+ } catch (error) {
5865
+ const code = error.code;
5866
+ if (code === "ENOENT") return {
5867
+ name,
5868
+ status: "ok",
5869
+ detail: `No ${AGENTS_DIR} directory — no agent file to check.`
5870
+ };
5871
+ return {
5872
+ name,
5873
+ status: "warn",
5874
+ detail: `Cannot read ${AGENTS_DIR}: ${code ?? error.message}.`,
5875
+ remediation: `Make ${AGENTS_DIR} a readable directory (fix its permissions, or remove what stands in its place), then re-run \`lemony doctor\`.`
5876
+ };
5877
+ }
5878
+ const checked = (await Promise.all(files.map(async (file) => {
5879
+ const path = `${AGENTS_DIR}/${file}`;
5880
+ try {
5881
+ const problem = inspectAgentFrontmatter(await readRegularFile(join(repoRoot, AGENTS_DIR, file)));
5882
+ return problem === null ? null : {
5883
+ path,
5884
+ problem
5885
+ };
5886
+ } catch (error) {
5887
+ const code = error.code;
5888
+ if (await stat(join(repoRoot, AGENTS_DIR, file)).then((target) => target.isDirectory(), () => false)) return "skipped";
5889
+ return {
5890
+ path,
5891
+ problem: {
5892
+ kind: "unreadable",
5893
+ reason: error.message === "the entry is not a regular file (a FIFO, a socket or a device), so it was not read." ? "not a regular file" : code ?? error.message
5894
+ }
5895
+ };
5896
+ }
5897
+ }))).filter((finding) => finding !== "skipped");
5898
+ const conflicted = [];
5899
+ const duplicated = [];
5900
+ const unreadable = [];
5901
+ const descriptions = [];
5902
+ for (const finding of checked) {
5903
+ if (finding === null) continue;
5904
+ const { path, problem } = finding;
5905
+ if (problem.kind === "conflict-markers") {
5906
+ conflicted.push(path);
5907
+ descriptions.push(`${path} has unresolved conflict markers`);
5908
+ } else if (problem.kind === "duplicate-keys") {
5909
+ duplicated.push(path);
5910
+ descriptions.push(`${path} declares ${problem.keys.map((key) => `\`${key}\``).join(", ")} more than once in its frontmatter`);
5911
+ } else {
5912
+ unreadable.push(path);
5913
+ descriptions.push(`${path} is unreadable (${problem.reason})`);
5914
+ }
5915
+ }
5916
+ if (checked.length === 0) return {
5917
+ name,
5918
+ status: "ok",
5919
+ detail: `No agent file in ${AGENTS_DIR} to check.`
5920
+ };
5921
+ if (descriptions.length === 0) return {
5922
+ name,
5923
+ status: "ok",
5924
+ detail: `All ${checked.length} agent files in ${AGENTS_DIR} are free of conflict markers and duplicate frontmatter keys.`
5925
+ };
5926
+ const remediations = [];
5927
+ if (conflicted.length > 0) remediations.push(`resolve the conflict in ${conflicted.join(" ")} by hand — keep the side you want and delete the \`<<<<<<< client\` / \`=======\` / \`>>>>>>> vendor\` lines (\`update\` and \`repair\` refuse to run while any remain)`);
5928
+ if (duplicated.length > 0) remediations.push(`edit ${duplicated.join(" ")} so each frontmatter key is declared once (merge the values if both matter — only one of them applies)`);
5929
+ if (unreadable.length > 0) remediations.push(`make ${unreadable.join(" ")} ${unreadable.length === 1 ? "a readable file" : "readable files"} (fix the permissions, repoint a broken symlink, or remove what is not a file), then re-run \`lemony doctor\``);
5930
+ const remediation = remediations.join("; ");
5931
+ return {
5932
+ name,
5933
+ status: conflicted.length + duplicated.length > 0 ? "fail" : "warn",
5934
+ detail: `${descriptions.join("; ")}.`,
5935
+ remediation: `${remediation.charAt(0).toUpperCase()}${remediation.slice(1)}.`
4111
5936
  };
4112
5937
  };
4113
5938
  const isReadable = async (path) => {
@@ -4118,10 +5943,89 @@ const isReadable = async (path) => {
4118
5943
  return false;
4119
5944
  }
4120
5945
  };
5946
+ const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
5947
+ //#endregion
5948
+ //#region src/status/live-branch.ts
5949
+ const REBASE_HEAD_NAME_FILES = [join("rebase-merge", "head-name"), join("rebase-apply", "head-name")];
5950
+ const REFS_HEADS = "refs/heads/";
5951
+ const stripRefsHeads = (text) => text.split("\n").filter((line) => line.startsWith(REFS_HEADS)).map((line) => line.slice(11)).join("\n");
5952
+ const readLiveBranch = async (repoRoot, run) => {
5953
+ const head = await run("git", [
5954
+ "-C",
5955
+ repoRoot,
5956
+ "symbolic-ref",
5957
+ "--quiet",
5958
+ "HEAD"
5959
+ ]);
5960
+ const attached = head.code === 0 ? stripRefsHeads(head.stdout.trim()) : "";
5961
+ if (attached !== "") return attached;
5962
+ const gitDir = await run("git", [
5963
+ "-C",
5964
+ repoRoot,
5965
+ "rev-parse",
5966
+ "--absolute-git-dir"
5967
+ ]);
5968
+ const dir = gitDir.code === 0 ? gitDir.stdout.trim() : "";
5969
+ if (dir === "") return null;
5970
+ const recorded = (await Promise.all(REBASE_HEAD_NAME_FILES.map(async (file) => {
5971
+ const path = join(dir, file);
5972
+ return (await stat(path).catch(() => void 0))?.isFile() ? readRegularFile(path).catch(() => "") : void 0;
5973
+ }))).find((content) => content !== void 0);
5974
+ return recorded === void 0 ? null : stripRefsHeads(recorded.trim()) || null;
5975
+ };
5976
+ //#endregion
5977
+ //#region src/status/git-behind.ts
5978
+ const readGitBehind = async (repoRoot, run) => {
5979
+ const branch = await readLiveBranch(repoRoot, run);
5980
+ if ((await run("git", [
5981
+ "-C",
5982
+ repoRoot,
5983
+ "symbolic-ref",
5984
+ "--quiet",
5985
+ "HEAD"
5986
+ ])).code !== 0) return {
5987
+ branch,
5988
+ behind: null
5989
+ };
5990
+ const headRef = await run("git", [
5991
+ "-C",
5992
+ repoRoot,
5993
+ "symbolic-ref",
5994
+ "--quiet",
5995
+ "refs/remotes/origin/HEAD"
5996
+ ]);
5997
+ if (headRef.code !== 0) return {
5998
+ branch,
5999
+ behind: null
6000
+ };
6001
+ const count = await run("git", [
6002
+ "-C",
6003
+ repoRoot,
6004
+ "rev-list",
6005
+ "--count",
6006
+ `HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
6007
+ ]);
6008
+ if (count.code !== 0) return {
6009
+ branch,
6010
+ behind: null
6011
+ };
6012
+ const parsed = Number.parseInt(count.stdout.trim(), 10);
6013
+ return {
6014
+ branch,
6015
+ behind: Number.isNaN(parsed) ? null : parsed
6016
+ };
6017
+ };
4121
6018
  //#endregion
4122
6019
  //#region src/status/parse-task-branch.ts
4123
- const TASK_BRANCH = /^harness\/(\d+)-.+$/;
4124
- const parseTaskBranch = (branch) => branch === null ? null : TASK_BRANCH.exec(branch)?.[1] ?? null;
6020
+ const TASK_BRANCHES = [/^harness\/(\d+)-.+$/, /^harness\/closeout-(\d+)$/];
6021
+ const parseTaskBranch = (branch) => {
6022
+ if (branch === null) return null;
6023
+ for (const shape of TASK_BRANCHES) {
6024
+ const id = shape.exec(branch)?.[1];
6025
+ if (id !== void 0) return id;
6026
+ }
6027
+ return null;
6028
+ };
4125
6029
  //#endregion
4126
6030
  //#region src/status/status.ts
4127
6031
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
@@ -4130,8 +6034,7 @@ const runStatus = async (deps) => {
4130
6034
  const config = await readHarnessConfig(repoRoot);
4131
6035
  const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
4132
6036
  const { branch, behind } = await deps.gitBehind(repoRoot);
4133
- const provider = createTaskTrackerProvider(config.task_storage.type, deps.runCommand);
4134
- const openDiscoveries = await countOpenDiscoveries(provider, config.task_storage.repo);
6037
+ const openDiscoveries = deps.local === true ? null : await countOpenDiscoveries(createTaskTrackerProvider(config.task_storage.type, deps.runCommand), config.task_storage.repo);
4135
6038
  return {
4136
6039
  vendorVersion: config.vendor_version,
4137
6040
  taskStorageRepo: config.task_storage.repo,
@@ -4140,15 +6043,16 @@ const runStatus = async (deps) => {
4140
6043
  behind,
4141
6044
  lastSession,
4142
6045
  openDiscoveries,
4143
- designToolDrift: await readDriftState(repoRoot)
6046
+ designToolDrift: await readDriftState(repoRoot, resolveTokenFile(config.design_tokens.file))
4144
6047
  };
4145
6048
  };
4146
- const readDriftState = async (repoRoot) => {
4147
- const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
4148
- if (!await pathExists(tokenPath)) return null;
6049
+ const readDriftState = async (repoRoot, tokenFile) => {
4149
6050
  try {
4150
- const parsed = JSON.parse(await readFile(tokenPath, "utf8"));
6051
+ const raw = await readOptionalTokenFile(repoRoot, tokenFile);
6052
+ if (raw === void 0) return null;
6053
+ const parsed = JSON.parse(raw);
4151
6054
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
6055
+ if (summarizeTokenFileViolations(raw, tokenFile) !== void 0) return null;
4152
6056
  return driftReport(parsed).state;
4153
6057
  } catch {
4154
6058
  return null;
@@ -4163,7 +6067,7 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
4163
6067
  const pointerPath = join(repoRoot, STATE_DIR, `current-${slug}.md`);
4164
6068
  let raw;
4165
6069
  try {
4166
- raw = await readFile(pointerPath, "utf8");
6070
+ raw = await readRegularFile(pointerPath);
4167
6071
  } catch {
4168
6072
  return empty;
4169
6073
  }
@@ -4223,8 +6127,8 @@ const COMMANDS = [
4223
6127
  },
4224
6128
  {
4225
6129
  name: "status",
4226
- summary: "Show installed version, branch drift, and open tasks.",
4227
- usage: "lemony status"
6130
+ summary: "Show installed version, branch drift, and open tasks (`--local`: no network call).",
6131
+ usage: "lemony status [--local]"
4228
6132
  },
4229
6133
  {
4230
6134
  name: "emit",
@@ -4236,9 +6140,14 @@ const COMMANDS = [
4236
6140
  summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
4237
6141
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
4238
6142
  },
6143
+ {
6144
+ name: "checkpoint",
6145
+ summary: "Resolve a human checkpoint answer in one call: the commits (each with a Lemony-Checkpoint trailer), a best-effort push and the step_completed emit, emit last; used by the Orchestrator.",
6146
+ usage: "lemony checkpoint <ok|changes|ok_downgrade> --task-id=<id> --auto-commit=<on|off> [--step=<N> --review-iterations=<K>] [--attributed-kind=<agent|skill|playbook> --attributed-name=<name>]"
6147
+ },
4239
6148
  {
4240
6149
  name: "design-tokens",
4241
- summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists; never created).",
6150
+ summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists: the gates never create the file; `import --apply` bootstraps it).",
4242
6151
  usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
4243
6152
  },
4244
6153
  {
@@ -4292,70 +6201,6 @@ const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match
4292
6201
  return vars[key];
4293
6202
  });
4294
6203
  //#endregion
4295
- //#region src/baseline/baseline.ts
4296
- const STAGING_DIR$1 = ".staging";
4297
- const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
4298
- const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
4299
- const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
4300
- const writeBaseline = async (repoRoot, version, files) => {
4301
- const root = baselineRootDir(repoRoot);
4302
- const staging = join(root, STAGING_DIR$1);
4303
- await rm(staging, {
4304
- recursive: true,
4305
- force: true
4306
- });
4307
- await mkdir(staging, { recursive: true });
4308
- await Promise.all(files.map(async ({ relPath, content }) => {
4309
- const dest = join(staging, relPath);
4310
- await mkdir(dirname(dest), { recursive: true });
4311
- await writeFile(dest, content);
4312
- }));
4313
- const versionDir = baselineVersionDir(repoRoot, version);
4314
- await rm(versionDir, {
4315
- recursive: true,
4316
- force: true
4317
- });
4318
- await rename(staging, versionDir);
4319
- await pruneOtherVersions(root, version);
4320
- };
4321
- const pruneOtherVersions = async (root, keep) => {
4322
- const entries = await readdir(root, { withFileTypes: true });
4323
- await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
4324
- recursive: true,
4325
- force: true
4326
- })));
4327
- };
4328
- const readBaseline = async (repoRoot, version) => {
4329
- const versionDir = baselineVersionDir(repoRoot, version);
4330
- let relPaths;
4331
- try {
4332
- relPaths = await listFiles(versionDir);
4333
- } catch (error) {
4334
- if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
4335
- throw error;
4336
- }
4337
- const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
4338
- return new Map(entries);
4339
- };
4340
- const findBaselineVersion = async (repoRoot) => {
4341
- const root = baselineRootDir(repoRoot);
4342
- let entries;
4343
- try {
4344
- entries = await readdir(root, { withFileTypes: true });
4345
- } catch (error) {
4346
- if (isENOENT$1(error)) return null;
4347
- throw error;
4348
- }
4349
- const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
4350
- if (versions.length <= 1) return versions[0] ?? null;
4351
- const withMtime = await Promise.all(versions.map(async (version) => ({
4352
- version,
4353
- mtimeMs: (await stat(join(root, version))).mtimeMs
4354
- })));
4355
- withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
4356
- return withMtime.at(-1)?.version ?? null;
4357
- };
4358
- //#endregion
4359
6204
  //#region src/snapshot/snapshot.ts
4360
6205
  const STAGING_DIR = ".staging";
4361
6206
  const WORKING_SUBDIR = "working";
@@ -4440,183 +6285,37 @@ const readSnapshot = async (repoRoot, version) => {
4440
6285
  const filePath = join(workingRoot, relPath);
4441
6286
  const [content, info] = await Promise.all([readFile(filePath, "utf8"), stat(filePath)]);
4442
6287
  return {
4443
- relPath,
4444
- content,
4445
- executable: (info.mode & 73) !== 0
4446
- };
4447
- }));
4448
- const baselineRoot = join(dir, BASELINE_SUBDIR);
4449
- const baselinePaths = await listFiles(baselineRoot);
4450
- const baselineEntries = await Promise.all(baselinePaths.map(async (relPath) => [relPath, await readFile(join(baselineRoot, relPath), "utf8")]));
4451
- return {
4452
- working,
4453
- baseline: new Map(baselineEntries)
4454
- };
4455
- };
4456
- const rotateSnapshots = async (repoRoot, keep) => {
4457
- if (keep === "unlimited") return [];
4458
- const toRemove = (await listSnapshots(repoRoot)).slice(keep);
4459
- await assertNoSymlinkTraversal(repoRoot, toRemove.map((version) => join(SNAPSHOTS_ROOT_REL, version)));
4460
- await Promise.all(toRemove.map((version) => rm(snapshotDir(repoRoot, version), {
4461
- recursive: true,
4462
- force: true
4463
- })));
4464
- return toRemove;
4465
- };
4466
- const cleanupSnapshots = async (repoRoot) => {
4467
- const versions = await listSnapshots(repoRoot);
4468
- await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
4469
- await rm(snapshotsRootDir(repoRoot), {
4470
- recursive: true,
4471
- force: true
4472
- });
4473
- return versions;
4474
- };
4475
- //#endregion
4476
- //#region src/merge/conflict-markers.constant.ts
4477
- const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
4478
- const CONFLICT_MARKER_SEPARATOR = "=======";
4479
- const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
4480
- //#endregion
4481
- //#region src/merge/conflict-markers.ts
4482
- const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
4483
- const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
4484
- //#endregion
4485
- //#region src/merge/three-way-merge.ts
4486
- const threeWayMerge = (base, client, vendor) => {
4487
- const baseLines = base.split("\n");
4488
- const clientLines = client.split("\n");
4489
- const vendorLines = vendor.split("\n");
4490
- const regions = diff3Regions(baseLines, clientLines, vendorLines);
4491
- const out = [];
4492
- let conflicted = false;
4493
- for (const region of regions) {
4494
- if (region.stable) {
4495
- out.push(...region.lines);
4496
- continue;
4497
- }
4498
- if (linesEqual(region.client, region.vendor)) out.push(...region.client);
4499
- else {
4500
- conflicted = true;
4501
- out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
4502
- }
4503
- }
4504
- return {
4505
- merged: out.join("\n"),
4506
- conflicted
4507
- };
4508
- };
4509
- const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
4510
- const diff3Regions = (base, client, vendor) => {
4511
- const hunks = [...diffRegions(base, client).map((d) => ({
4512
- ...d,
4513
- side: "client"
4514
- })), ...diffRegions(base, vendor).map((d) => ({
4515
- ...d,
4516
- side: "vendor"
4517
- }))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
4518
- const regions = [];
4519
- let baseCursor = 0;
4520
- let i = 0;
4521
- const emitStable = (until) => {
4522
- if (until > baseCursor) {
4523
- regions.push({
4524
- stable: true,
4525
- lines: base.slice(baseCursor, until)
4526
- });
4527
- baseCursor = until;
4528
- }
4529
- };
4530
- while (i < hunks.length) {
4531
- const first = hunks[i];
4532
- if (first === void 0) break;
4533
- let regionBaseStart = first.baseStart;
4534
- let regionBaseEnd = first.baseEnd;
4535
- const group = [first];
4536
- i += 1;
4537
- while (i < hunks.length) {
4538
- const next = hunks[i];
4539
- if (next === void 0 || next.baseStart > regionBaseEnd) break;
4540
- regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
4541
- group.push(next);
4542
- i += 1;
4543
- }
4544
- emitStable(regionBaseStart);
4545
- const touchedByClient = group.some((h) => h.side === "client");
4546
- const touchedByVendor = group.some((h) => h.side === "vendor");
4547
- if (touchedByClient && touchedByVendor) regions.push({
4548
- stable: false,
4549
- client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
4550
- vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
4551
- });
4552
- else {
4553
- const side = touchedByClient ? "client" : "vendor";
4554
- regions.push({
4555
- stable: true,
4556
- lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
4557
- });
4558
- }
4559
- baseCursor = regionBaseEnd;
4560
- }
4561
- emitStable(base.length);
4562
- return regions;
6288
+ relPath,
6289
+ content,
6290
+ executable: (info.mode & 73) !== 0
6291
+ };
6292
+ }));
6293
+ const baselineRoot = join(dir, BASELINE_SUBDIR);
6294
+ const baselinePaths = await listFiles(baselineRoot);
6295
+ const baselineEntries = await Promise.all(baselinePaths.map(async (relPath) => [relPath, await readFile(join(baselineRoot, relPath), "utf8")]));
6296
+ return {
6297
+ working,
6298
+ baseline: new Map(baselineEntries)
6299
+ };
4563
6300
  };
4564
- const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
4565
- const own = group.filter((h) => h.side === side);
4566
- let sideStart = Number.POSITIVE_INFINITY;
4567
- let sideEnd = Number.NEGATIVE_INFINITY;
4568
- let baseStart = Number.POSITIVE_INFINITY;
4569
- let baseEnd = Number.NEGATIVE_INFINITY;
4570
- for (const h of own) {
4571
- sideStart = Math.min(sideStart, h.sideStart);
4572
- sideEnd = Math.max(sideEnd, h.sideEnd);
4573
- baseStart = Math.min(baseStart, h.baseStart);
4574
- baseEnd = Math.max(baseEnd, h.baseEnd);
4575
- }
4576
- const start = sideStart - (baseStart - regionBaseStart);
4577
- const end = sideEnd + (regionBaseEnd - baseEnd);
4578
- return sideLines.slice(start, end);
6301
+ const rotateSnapshots = async (repoRoot, keep) => {
6302
+ if (keep === "unlimited") return [];
6303
+ const toRemove = (await listSnapshots(repoRoot)).slice(keep);
6304
+ await assertNoSymlinkTraversal(repoRoot, toRemove.map((version) => join(SNAPSHOTS_ROOT_REL, version)));
6305
+ await Promise.all(toRemove.map((version) => rm(snapshotDir(repoRoot, version), {
6306
+ recursive: true,
6307
+ force: true
6308
+ })));
6309
+ return toRemove;
4579
6310
  };
4580
- const diffRegions = (base, other) => {
4581
- const matches = lcsMatches(base, other);
4582
- const diffs = [];
4583
- let baseTail = 0;
4584
- let otherTail = 0;
4585
- for (const [baseIdx, otherIdx] of matches) {
4586
- if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
4587
- baseStart: baseTail,
4588
- baseEnd: baseIdx,
4589
- sideStart: otherTail,
4590
- sideEnd: otherIdx
4591
- });
4592
- baseTail = baseIdx + 1;
4593
- otherTail = otherIdx + 1;
4594
- }
4595
- if (base.length > baseTail || other.length > otherTail) diffs.push({
4596
- baseStart: baseTail,
4597
- baseEnd: base.length,
4598
- sideStart: otherTail,
4599
- sideEnd: other.length
6311
+ const cleanupSnapshots = async (repoRoot) => {
6312
+ const versions = await listSnapshots(repoRoot);
6313
+ await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
6314
+ await rm(snapshotsRootDir(repoRoot), {
6315
+ recursive: true,
6316
+ force: true
4600
6317
  });
4601
- return diffs;
4602
- };
4603
- const lcsMatches = (base, other) => {
4604
- const n = base.length;
4605
- const m = other.length;
4606
- const width = m + 1;
4607
- const dp = new Int32Array((n + 1) * width);
4608
- const at = (i, j) => dp[i * width + j] ?? 0;
4609
- for (let i = n - 1; i >= 0; i -= 1) for (let j = m - 1; j >= 0; j -= 1) dp[i * width + j] = base[i] === other[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
4610
- const matches = [];
4611
- let i = 0;
4612
- let j = 0;
4613
- while (i < n && j < m) if (base[i] === other[j]) {
4614
- matches.push([i, j]);
4615
- i += 1;
4616
- j += 1;
4617
- } else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
4618
- else j += 1;
4619
- return matches;
6318
+ return versions;
4620
6319
  };
4621
6320
  //#endregion
4622
6321
  //#region src/update/engine.ts
@@ -4808,54 +6507,6 @@ const materializeVendorFiles = async (ctx) => {
4808
6507
  ];
4809
6508
  };
4810
6509
  //#endregion
4811
- //#region src/install/gitignore.ts
4812
- const GITIGNORE_BEGIN = "# BEGIN lemony";
4813
- const GITIGNORE_END = "# END lemony";
4814
- const GITIGNORE_BLOCK = [
4815
- GITIGNORE_BEGIN,
4816
- ".claude/state/current-*.md",
4817
- ".claude/state/.ptr.*",
4818
- ".claude/state/sessions/",
4819
- ".claude/state/events.jsonl",
4820
- ".claude/state/telemetry-cursor.json",
4821
- ".claude/state/telemetry-consent.json",
4822
- ".claude/state/.telemetry-notice-shown",
4823
- ".claude/state/telemetry-rejected.jsonl",
4824
- ".claude/.harness/snapshots/",
4825
- GITIGNORE_END
4826
- ].join("\n");
4827
- const appendGitignoreBlock = async (repoRoot) => {
4828
- const gitignorePath = join(repoRoot, ".gitignore");
4829
- const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
4830
- const beginIdx = current.indexOf(GITIGNORE_BEGIN);
4831
- const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
4832
- if (beginIdx !== -1 && endIdx !== -1) {
4833
- const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
4834
- if (next === current) return null;
4835
- await writeFile(gitignorePath, next);
4836
- return ".gitignore";
4837
- }
4838
- const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
4839
- await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
4840
- return ".gitignore";
4841
- };
4842
- const removeGitignoreBlock = async (repoRoot) => {
4843
- const relPath = ".gitignore";
4844
- const gitignorePath = join(repoRoot, relPath);
4845
- if (!await pathExists(gitignorePath)) return null;
4846
- const lines = (await readFile(gitignorePath, "utf8")).split("\n");
4847
- const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
4848
- const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
4849
- if (startLine === -1 || endLine === -1) return null;
4850
- const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
4851
- if (result.trim() === "") {
4852
- await rm(gitignorePath, { force: true });
4853
- return relPath;
4854
- }
4855
- await writeFile(gitignorePath, result);
4856
- return relPath;
4857
- };
4858
- //#endregion
4859
6510
  //#region src/install/merge-settings.ts
4860
6511
  const mergeSettingsHooks = async (templatePath, destPath) => {
4861
6512
  const templateRaw = await readFile(templatePath, "utf8");
@@ -4863,7 +6514,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4863
6514
  if (template.hooks === void 0) throw new Error(`settings template at ${templatePath} has no "hooks" key — vendor authority is the hooks block.`);
4864
6515
  let existing = {};
4865
6516
  if (await pathExists(destPath)) {
4866
- const existingRaw = await readFile(destPath, "utf8");
6517
+ const existingRaw = await readSettings(destPath, mergeContext(destPath));
4867
6518
  if (existingRaw.trim().length > 0) try {
4868
6519
  existing = JSON.parse(existingRaw);
4869
6520
  } catch (cause) {
@@ -4881,7 +6532,7 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4881
6532
  };
4882
6533
  const removeSettingsHooks = async (settingsPath) => {
4883
6534
  if (!await pathExists(settingsPath)) return "absent";
4884
- const raw = await readFile(settingsPath, "utf8");
6535
+ const raw = await readSettings(settingsPath, removeContext(settingsPath));
4885
6536
  if (raw.trim().length === 0) return "absent";
4886
6537
  let parsed;
4887
6538
  try {
@@ -4898,6 +6549,24 @@ const removeSettingsHooks = async (settingsPath) => {
4898
6549
  await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
4899
6550
  return "stripped";
4900
6551
  };
6552
+ const mergeContext = (path) => `Cannot merge the harness hooks into ${path}`;
6553
+ const removeContext = (path) => `Cannot remove the harness hooks from ${path}`;
6554
+ const assertSettingsNotSpecial = async (path, action) => {
6555
+ const target = await stat(path).catch(() => void 0);
6556
+ if (target === void 0 || target.isFile()) return;
6557
+ const context = action === "merge" ? mergeContext(path) : removeContext(path);
6558
+ const reason = target.isDirectory() ? "the entry is a directory." : SPECIAL_FILE_REFUSAL;
6559
+ throw new Error(`${context}: ${reason} ${REMOVE_IT}`);
6560
+ };
6561
+ const REMOVE_IT = "Remove it before re-running.";
6562
+ const readSettings = async (path, context) => {
6563
+ try {
6564
+ return await readRegularFile(path);
6565
+ } catch (error) {
6566
+ if (error.code !== void 0) throw error;
6567
+ throw new Error(`${context}: ${error.message} ${REMOVE_IT}`, { cause: error });
6568
+ }
6569
+ };
4901
6570
  const collectCommands = (hooks) => {
4902
6571
  const result = /* @__PURE__ */ new Set();
4903
6572
  if (!hooks || typeof hooks !== "object") return result;
@@ -4922,6 +6591,54 @@ const diffLostCommands = (existingHooks, templateHooks) => {
4922
6591
  return lost.toSorted();
4923
6592
  };
4924
6593
  //#endregion
6594
+ //#region src/install/gitignore.ts
6595
+ const GITIGNORE_BEGIN = "# BEGIN lemony";
6596
+ const GITIGNORE_END = "# END lemony";
6597
+ const GITIGNORE_BLOCK = [
6598
+ GITIGNORE_BEGIN,
6599
+ ".claude/state/current-*.md",
6600
+ ".claude/state/.ptr.*",
6601
+ ".claude/state/sessions/",
6602
+ ".claude/state/events.jsonl",
6603
+ ".claude/state/telemetry-cursor.json",
6604
+ ".claude/state/telemetry-consent.json",
6605
+ ".claude/state/.telemetry-notice-shown",
6606
+ ".claude/state/telemetry-rejected.jsonl",
6607
+ ".claude/.harness/snapshots/",
6608
+ GITIGNORE_END
6609
+ ].join("\n");
6610
+ const appendGitignoreBlock = async (repoRoot) => {
6611
+ const gitignorePath = join(repoRoot, ".gitignore");
6612
+ const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
6613
+ const beginIdx = current.indexOf(GITIGNORE_BEGIN);
6614
+ const endIdx = current.indexOf(GITIGNORE_END, beginIdx);
6615
+ if (beginIdx !== -1 && endIdx !== -1) {
6616
+ const next = current.slice(0, beginIdx) + GITIGNORE_BLOCK + current.slice(endIdx + 12);
6617
+ if (next === current) return null;
6618
+ await writeFile(gitignorePath, next);
6619
+ return ".gitignore";
6620
+ }
6621
+ const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
6622
+ await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
6623
+ return ".gitignore";
6624
+ };
6625
+ const removeGitignoreBlock = async (repoRoot) => {
6626
+ const relPath = ".gitignore";
6627
+ const gitignorePath = join(repoRoot, relPath);
6628
+ if (!await pathExists(gitignorePath)) return null;
6629
+ const lines = (await readFile(gitignorePath, "utf8")).split("\n");
6630
+ const startLine = lines.findIndex((line) => line.trim() === GITIGNORE_BEGIN);
6631
+ const endLine = startLine === -1 ? -1 : lines.findIndex((line, index) => index > startLine && line.trim() === GITIGNORE_END);
6632
+ if (startLine === -1 || endLine === -1) return null;
6633
+ const result = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)].join("\n").replace(/\n+$/, "\n");
6634
+ if (result.trim() === "") {
6635
+ await rm(gitignorePath, { force: true });
6636
+ return relPath;
6637
+ }
6638
+ await writeFile(gitignorePath, result);
6639
+ return relPath;
6640
+ };
6641
+ //#endregion
4925
6642
  //#region src/install/resync.ts
4926
6643
  const resyncSpecialCased = async (repoRoot, templateRoot) => {
4927
6644
  const { lostCommands } = await mergeSettingsHooks(join(templateRoot, CLAUDE_DIR, "settings.json.tpl"), join(repoRoot, SETTINGS_FILE));
@@ -4978,6 +6695,7 @@ const runInstall = async (options) => {
4978
6695
  ...COMMITTED_STATE_DIRS.map((dir) => join(STATE_DIR, dir, ".gitkeep")),
4979
6696
  HARNESS_CONFIG_FILENAME
4980
6697
  ]);
6698
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
4981
6699
  const readClient = async (relPath) => {
4982
6700
  const filePath = join(repoRoot, relPath);
4983
6701
  if (!await pathExists(filePath)) return null;
@@ -5126,6 +6844,7 @@ const runReconcile = async (inputs) => {
5126
6844
  SETTINGS_FILE,
5127
6845
  ".gitignore"
5128
6846
  ]);
6847
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "merge");
5129
6848
  const actions = await runEngine({
5130
6849
  baseline,
5131
6850
  vendorNew,
@@ -5270,7 +6989,7 @@ const runRepair = async (options) => {
5270
6989
  const DOCS_PREFIX = `docs${sep}`;
5271
6990
  const runUninstall = async (options) => {
5272
6991
  const { repoRoot } = options;
5273
- if (!await pathExists(join(repoRoot, "harness.config.yml"))) throw new Error(`Lemony is not installed here (${HARNESS_CONFIG_FILENAME} not found).`);
6992
+ if (!await entryExists(join(repoRoot, "harness.config.yml")) && !await hasInstallBaseline(repoRoot)) throw new Error(`Lemony is not installed here (${HARNESS_CONFIG_FILENAME} not found).`);
5274
6993
  const baselineVersion = await findBaselineVersion(repoRoot);
5275
6994
  if (baselineVersion === null) throw new Error("Refusing to uninstall — no baseline registry under .claude/.harness/baseline/. The baseline is the record of which files are vendor-managed; without it uninstall cannot distinguish vendor files from your own. Run `lemony repair` to restore it, or remove the harness files manually.");
5276
6995
  const config = await readHarnessConfig(repoRoot);
@@ -5282,6 +7001,7 @@ const runUninstall = async (options) => {
5282
7001
  HARNESS_DIR,
5283
7002
  HARNESS_CONFIG_FILENAME
5284
7003
  ]);
7004
+ await assertSettingsNotSpecial(join(repoRoot, SETTINGS_FILE), "remove");
5285
7005
  await Promise.all(removedFiles.map((relPath) => rm(join(repoRoot, relPath), { force: true })));
5286
7006
  await pruneEmptyDirs(repoRoot, removedFiles);
5287
7007
  const settings = await removeSettingsHooks(join(repoRoot, SETTINGS_FILE));
@@ -5493,8 +7213,32 @@ const PACKAGE_JSON_PATH = join(PACKAGE_ROOT, "package.json");
5493
7213
  const execFileAsync = promisify(execFile);
5494
7214
  const parseFlag = (args, name) => {
5495
7215
  const prefix = `--${name}=`;
5496
- const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
5497
- return value ? value : void 0;
7216
+ const joined = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
7217
+ if (joined !== void 0) return joined ? joined : void 0;
7218
+ const at = args.indexOf(`--${name}`);
7219
+ const next = at === -1 ? void 0 : args[at + 1];
7220
+ return next !== void 0 && next !== "" && !next.startsWith("--") ? next : void 0;
7221
+ };
7222
+ const rejectValuelessFlags = (verb, args, names) => {
7223
+ for (const name of names) if (args.some((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`)) && parseFlag(args, name) === void 0) throw new Error(`${verb}: --${name} was given without a value.`);
7224
+ };
7225
+ const rejectStrayArgs = (verb, args, valued) => {
7226
+ const seen = /* @__PURE__ */ new Set();
7227
+ let positionals = 0;
7228
+ for (let i = 0; i < args.length; i++) {
7229
+ const arg = args[i];
7230
+ if (!arg.startsWith("--")) {
7231
+ positionals += 1;
7232
+ if (positionals > 1) throw new Error(`${verb}: unexpected argument "${arg}".`);
7233
+ continue;
7234
+ }
7235
+ const name = arg.slice(2).split("=")[0];
7236
+ if (!valued.includes(name)) throw new Error(`${verb}: unknown flag --${name}.`);
7237
+ if (seen.has(name)) throw new Error(`${verb}: --${name} was given more than once.`);
7238
+ seen.add(name);
7239
+ const next = args[i + 1];
7240
+ if (!arg.includes("=") && next !== void 0 && !next.startsWith("--")) i += 1;
7241
+ }
5498
7242
  };
5499
7243
  const readHarnessVersion = async () => {
5500
7244
  const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
@@ -5545,7 +7289,8 @@ const makeVerifierRunCommand = () => (cmd, args) => new Promise((resolveResult)
5545
7289
  resolveResult({
5546
7290
  code: code ?? 1,
5547
7291
  stdout: Buffer.concat(out).toString("utf8"),
5548
- stderr: `${Buffer.concat(err).toString("utf8")}${signal ? `killed by ${signal}\n` : ""}`
7292
+ stderr: Buffer.concat(err).toString("utf8"),
7293
+ ...signal === null ? {} : { signal }
5549
7294
  });
5550
7295
  });
5551
7296
  });
@@ -5566,45 +7311,6 @@ const buildResolveDeps = () => {
5566
7311
  close: () => rl.close()
5567
7312
  };
5568
7313
  };
5569
- const gitBehind = async (repoRoot) => {
5570
- const run = makeRunCommand();
5571
- const branchResult = await run("git", [
5572
- "-C",
5573
- repoRoot,
5574
- "symbolic-ref",
5575
- "--quiet",
5576
- "HEAD"
5577
- ]);
5578
- const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
5579
- const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
5580
- const headRef = await run("git", [
5581
- "-C",
5582
- repoRoot,
5583
- "symbolic-ref",
5584
- "--quiet",
5585
- "refs/remotes/origin/HEAD"
5586
- ]);
5587
- if (headRef.code !== 0) return {
5588
- branch,
5589
- behind: null
5590
- };
5591
- const count = await run("git", [
5592
- "-C",
5593
- repoRoot,
5594
- "rev-list",
5595
- "--count",
5596
- `HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
5597
- ]);
5598
- if (count.code !== 0) return {
5599
- branch,
5600
- behind: null
5601
- };
5602
- const parsed = Number.parseInt(count.stdout.trim(), 10);
5603
- return {
5604
- branch,
5605
- behind: Number.isNaN(parsed) ? null : parsed
5606
- };
5607
- };
5608
7314
  const install = async (args) => {
5609
7315
  const repoRoot = cwd();
5610
7316
  if (await pathExists(join(repoRoot, "harness.config.yml"))) {
@@ -5929,6 +7635,42 @@ const discovery = async (args) => {
5929
7635
  if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
5930
7636
  exit(1);
5931
7637
  };
7638
+ const checkpoint = async (args) => {
7639
+ const valued = [
7640
+ "task-id",
7641
+ "auto-commit",
7642
+ "step",
7643
+ "review-iterations",
7644
+ "attributed-kind",
7645
+ "attributed-name"
7646
+ ];
7647
+ rejectValuelessFlags("checkpoint", args, valued);
7648
+ rejectStrayArgs("checkpoint", args, valued);
7649
+ const result = await runCheckpoint({
7650
+ repoRoot: cwd(),
7651
+ answer: args[0]?.startsWith("--") ? void 0 : args[0],
7652
+ taskId: parseFlag(args, "task-id"),
7653
+ autoCommit: parseFlag(args, "auto-commit"),
7654
+ step: parseFlag(args, "step"),
7655
+ reviewIterations: parseFlag(args, "review-iterations"),
7656
+ attributedKind: parseFlag(args, "attributed-kind"),
7657
+ attributedName: parseFlag(args, "attributed-name"),
7658
+ harnessVersion: await readHarnessVersion(),
7659
+ runCommand: makeRunCommand()
7660
+ });
7661
+ if (result.ok) {
7662
+ console.log(`Checkpoint ${args[0]} resolved:`);
7663
+ for (const line of result.done) console.log(` ${line}`);
7664
+ for (const warning of result.warnings) console.error(` Warning: ${warning}`);
7665
+ return;
7666
+ }
7667
+ console.error(`checkpoint ${args[0]} failed at ${result.failedStep}: ${result.reason}`);
7668
+ for (const line of result.done) console.error(` Done before failure: ${line}`);
7669
+ for (const warning of result.warnings) console.error(` Warning: ${warning}`);
7670
+ console.error(" Fix the cause and run the same command again: what already landed is skipped, and the emit runs once, last.");
7671
+ exit(1);
7672
+ };
7673
+ const tokenFileOf = (config) => resolveTokenFile(config?.design_tokens.file);
5932
7674
  const designTokens = async (args) => {
5933
7675
  const action = args[0] ?? "validate";
5934
7676
  if (action === "validate") return designTokensValidate(args);
@@ -5938,18 +7680,20 @@ const designTokens = async (args) => {
5938
7680
  throw new Error(`Usage: lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>. Unknown action "${action}".`);
5939
7681
  };
5940
7682
  const designTokensValidate = async (args) => {
5941
- const config = await readHarnessConfig(cwd()).catch(() => void 0);
7683
+ const config = await readOptionalHarnessConfig(cwd());
7684
+ const tokenFile = tokenFileOf(config);
5942
7685
  const result = await runValidate({
5943
7686
  repoRoot: cwd(),
7687
+ tokenFile,
5944
7688
  scanDir: parseFlag(args, "scan"),
5945
7689
  extraExtensions: config?.design_tokens.scan_extensions
5946
7690
  });
5947
7691
  if (!result.tokensFound) {
5948
- console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the harness never creates it).`);
7692
+ console.log(`No ${tokenFile} — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
5949
7693
  return;
5950
7694
  }
5951
7695
  if (result.ok) {
5952
- console.log(`design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
7696
+ console.log(result.filesScanned === 0 ? `design-tokens valid: ${result.tokenCount} token(s), but 0 file(s) scanned — no readable source with a scanned extension was found, so nothing was checked for hardcoded values.` : `design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
5953
7697
  return;
5954
7698
  }
5955
7699
  console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
@@ -5959,19 +7703,32 @@ const designTokensValidate = async (args) => {
5959
7703
  }
5960
7704
  exit(1);
5961
7705
  };
7706
+ const verifierEnding = (outcome) => outcome.signal === void 0 ? `exited ${outcome.code}` : `was killed by ${outcome.signal}`;
7707
+ const quotedCommand = (command) => {
7708
+ const lines = command.trimEnd().split("\n");
7709
+ return lines.length > 1 ? `\`${lines[0]} …\`` : `\`${command}\``;
7710
+ };
7711
+ const drained = (stream) => new Promise((resolve) => {
7712
+ stream.once("error", () => resolve());
7713
+ stream.write("", () => resolve());
7714
+ });
7715
+ const contrastFailedLine = (pairCheck, verify) => {
7716
+ if (verify === void 0) return `design-tokens contrast failed: ${pairCheck}.`;
7717
+ const outcome = verify.code === 0 && verify.signal === void 0 ? "passed (exited 0)" : verifierEnding(verify);
7718
+ return `design-tokens contrast failed: ${pairCheck}; design_tokens.verify ${quotedCommand(verify.command)} ${outcome}.`;
7719
+ };
5962
7720
  const designTokensContrast = async () => {
5963
- const verify = (await pathExists(join(cwd(), "harness.config.yml")) ? await readHarnessConfig(cwd()) : void 0)?.design_tokens.verify;
7721
+ const config = await readOptionalHarnessConfig(cwd());
7722
+ const verify = config?.design_tokens.verify;
7723
+ const tokenFile = tokenFileOf(config);
5964
7724
  const result = await runContrast({
5965
7725
  repoRoot: cwd(),
7726
+ tokenFile,
5966
7727
  ...verify === void 0 ? {} : { verify: {
5967
7728
  command: verify,
5968
7729
  run: makeVerifierRunCommand()
5969
7730
  } }
5970
7731
  });
5971
- if (!result.tokensFound) {
5972
- console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5973
- return;
5974
- }
5975
7732
  const failures = result.pairs.filter((pair) => !pair.passes);
5976
7733
  const forwardVerifier = () => {
5977
7734
  if (result.verify === void 0) return;
@@ -5979,13 +7736,28 @@ const designTokensContrast = async () => {
5979
7736
  if (out.length > 0) stdout.write(out.endsWith("\n") ? out : `${out}\n`);
5980
7737
  if (err.length > 0) stderr.write(err.endsWith("\n") ? err : `${err}\n`);
5981
7738
  };
7739
+ const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
7740
+ if (!result.tokensFound) {
7741
+ console.log(result.verify === void 0 ? `No ${tokenFile} — design-tokens contrast skipped (consume-if-exists; the gates never create it).` : `No ${tokenFile} — design-tokens contrast pair check skipped (consume-if-exists; the gates never create it); running the declared design_tokens.verify.`);
7742
+ if (result.verify === void 0) return;
7743
+ if (failedVerifier) console.error(`design-tokens contrast found 1 issue(s):`);
7744
+ forwardVerifier();
7745
+ await Promise.all([drained(stdout), drained(stderr)]);
7746
+ if (failedVerifier) {
7747
+ console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
7748
+ console.error(contrastFailedLine(`pair check skipped (no ${tokenFile})`, failedVerifier));
7749
+ process.exitCode = 1;
7750
+ return;
7751
+ }
7752
+ console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
7753
+ return;
7754
+ }
5982
7755
  if (result.ok) {
5983
7756
  console.log(result.pairsChecked === 0 ? `design-tokens contrast: no foreground/background pairs found (use the on-* convention or $extensions["com.lemony.contrast"]).` : `design-tokens contrast OK: ${result.pairsChecked} pair(s) meet their WCAG floor.`);
5984
7757
  forwardVerifier();
5985
- if (result.verify !== void 0) console.log(`design-tokens verify OK: \`${result.verify.command}\` exited 0.`);
7758
+ if (result.verify !== void 0) console.log(`design-tokens verify OK: ${quotedCommand(result.verify.command)} exited 0.`);
5986
7759
  return;
5987
7760
  }
5988
- const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
5989
7761
  console.error(`design-tokens contrast found ${failures.length + result.problems.length + (failedVerifier ? 1 : 0)} issue(s):`);
5990
7762
  for (const pair of failures) {
5991
7763
  const over = pair.over === void 0 ? "" : ` over ${pair.over}`;
@@ -5993,54 +7765,80 @@ const designTokensContrast = async () => {
5993
7765
  }
5994
7766
  for (const problem of result.problems) console.error(` ${problem}`);
5995
7767
  forwardVerifier();
5996
- if (failedVerifier) console.error(` design_tokens.verify \`${failedVerifier.command}\` exited ${failedVerifier.code}.`);
7768
+ await Promise.all([drained(stdout), drained(stderr)]);
7769
+ if (failedVerifier) console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
7770
+ const pairIssues = failures.length + result.problems.length;
7771
+ console.error(contrastFailedLine(pairIssues > 0 ? result.pairsChecked === 0 ? `pair check measured no pair (${pairIssues} issue(s) above)` : `pair check found ${pairIssues} issue(s)` : result.pairsChecked === 0 ? "pair check found no pairs" : "pair check passed", result.verify));
5997
7772
  process.exitCode = 1;
5998
7773
  };
5999
7774
  const designTokensImport = async (args) => {
6000
7775
  const from = parseFlag(args, "from");
6001
7776
  if (from === void 0) throw new Error("Usage: lemony design-tokens import --from=<neutral-file> [--apply] [--only=<dotted,paths>]");
7777
+ rejectValuelessFlags("design-tokens import", args, ["only"]);
6002
7778
  const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
7779
+ if (only?.length === 0) throw new Error("design-tokens import: --only names no path.");
7780
+ const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
6003
7781
  const result = await runImport({
6004
7782
  repoRoot: cwd(),
7783
+ tokenFile,
6005
7784
  neutralPath: resolve(cwd(), from),
6006
7785
  apply: args.includes("--apply"),
6007
7786
  only
6008
7787
  });
6009
7788
  if (result.problems.length > 0) {
6010
- console.error("design-tokens import could not run:");
7789
+ console.error(result.refused ? "design-tokens import --apply refused:" : "design-tokens import could not run:");
6011
7790
  for (const problem of result.problems) console.error(` ${problem}`);
6012
- exit(1);
7791
+ if (result.changes.length > 0) printImportPreview(result);
7792
+ process.exitCode = 1;
7793
+ return;
6013
7794
  }
6014
7795
  if (result.applied) {
6015
- console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${DESIGN_TOKENS_FILE}.`);
7796
+ console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${tokenFile}.`);
6016
7797
  if (result.skipped.length > 0) console.log(` skipped ${result.skipped.length} path(s) that would clobber an existing group or token — resolve by hand: ${result.skipped.join(", ")}`);
7798
+ if (result.unsynced.length > 0) console.log(` not applied: ${result.unsynced.length} token(s) outside the design-tool sync, which one tool value cannot replace: ${result.unsynced.join(", ")}`);
6017
7799
  return;
6018
7800
  }
7801
+ const landing = printImportPreview(result);
7802
+ for (const warning of result.warnings) console.error(` Warning: ${warning}`);
7803
+ if (landing > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
7804
+ };
7805
+ const printImportPreview = (result) => {
6019
7806
  const landing = result.changes.filter((change) => change.kind !== "unchanged");
6020
7807
  const news = landing.filter((change) => change.kind === "new").length;
6021
7808
  const changed = landing.length - news;
6022
7809
  console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
6023
7810
  for (const change of landing) {
6024
- const detail = change.kind === "changed" ? `${change.before} → ${change.after}` : change.after;
7811
+ const detail = change.kind === "changed" ? changedDetail(change) : change.after;
6025
7812
  console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
6026
7813
  }
6027
- if (landing.length > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
7814
+ return landing.length;
6028
7815
  };
7816
+ const changedDetail = (change) => {
7817
+ const parts = [];
7818
+ if (change.before !== change.after) parts.push(`${shown(change.before)} → ${shown(change.after)}`);
7819
+ if (change.typeChange) parts.push(`$type ${shown(change.typeChange.before)} → ${shown(change.typeChange.after)}`);
7820
+ for (const mode of change.modeChanges ?? []) parts.push(`${mode.mode}: ${shown(mode.before)} → ${shown(mode.after)}`);
7821
+ return parts.join("; ");
7822
+ };
7823
+ const shown = (value) => value === void 0 || value === "" ? "(none)" : value;
6029
7824
  const designTokensExport = async (args) => {
7825
+ rejectValuelessFlags("design-tokens export", args, ["tool-state", "out"]);
6030
7826
  const toolState = parseFlag(args, "tool-state");
6031
7827
  const out = parseFlag(args, "out");
6032
7828
  if (out !== void 0 && args.includes("--record")) {
6033
7829
  console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
6034
7830
  exit(1);
6035
7831
  }
7832
+ const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
6036
7833
  const result = await runExport({
6037
7834
  repoRoot: cwd(),
7835
+ tokenFile,
6038
7836
  ...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
6039
7837
  ...out ? { outPath: resolve(cwd(), out) } : {},
6040
7838
  record: args.includes("--record")
6041
7839
  });
6042
7840
  if (!result.tokensFound) {
6043
- console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; the harness never creates it).`);
7841
+ console.log(`No ${tokenFile} — design-tokens export skipped (consume-if-exists; export never creates it).`);
6044
7842
  return;
6045
7843
  }
6046
7844
  if (result.problems.length > 0) {
@@ -6055,6 +7853,7 @@ const designTokensExport = async (args) => {
6055
7853
  const creates = result.plan.filter((entry) => entry.kind === "create").length;
6056
7854
  const updates = result.plan.filter((entry) => entry.kind === "update").length;
6057
7855
  console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
7856
+ if (result.unprojected.length > 0) console.log(` not projected: ${result.unprojected.length} token(s) a design tool variable cannot hold: ${result.unprojected.map(({ path, reason }) => `${path} (${reason})`).join(", ")}`);
6058
7857
  if (out !== void 0) console.log(` projection written to ${out}`);
6059
7858
  };
6060
7859
  const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";
@@ -6128,12 +7927,14 @@ const spinoff = async (args) => {
6128
7927
  console.error(`spinoff failed at "${result.failedStep}": ${result.reason}`);
6129
7928
  exit(1);
6130
7929
  };
6131
- const status = async () => {
7930
+ const status = async (args) => {
7931
+ const local = args.includes("--local");
6132
7932
  const report = await runStatus({
6133
7933
  repoRoot: cwd(),
6134
7934
  readGitUserEmail,
6135
- gitBehind,
6136
- runCommand: makeRunCommand()
7935
+ gitBehind: (root) => readGitBehind(root, makeRunCommand()),
7936
+ runCommand: makeRunCommand(),
7937
+ local
6137
7938
  });
6138
7939
  const behindText = report.behind === null ? "behind unknown" : report.behind === 0 ? "up to date" : `${report.behind} behind`;
6139
7940
  const discoveries = report.openDiscoveries === null ? "unknown" : String(report.openDiscoveries);
@@ -6143,7 +7944,7 @@ const status = async () => {
6143
7944
  console.log(` Active task: ${report.activeTask ?? "(none)"}`);
6144
7945
  console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
6145
7946
  console.log(` Last session: ${report.lastSession ?? "(none)"}`);
6146
- console.log(` Open discoveries: ${discoveries}`);
7947
+ if (!local) console.log(` Open discoveries: ${discoveries}`);
6147
7948
  if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
6148
7949
  };
6149
7950
  const DESIGN_TOOL_STATUS = {
@@ -6230,6 +8031,9 @@ const main = async () => {
6230
8031
  case "discovery":
6231
8032
  await discovery(args);
6232
8033
  return;
8034
+ case "checkpoint":
8035
+ await checkpoint(args);
8036
+ return;
6233
8037
  case "design-tokens":
6234
8038
  await designTokens(args);
6235
8039
  return;
@@ -6243,7 +8047,7 @@ const main = async () => {
6243
8047
  await runTelemetry(args);
6244
8048
  return;
6245
8049
  case "status":
6246
- await status();
8050
+ await status(args);
6247
8051
  return;
6248
8052
  case "doctor":
6249
8053
  await doctor();