@lemoncode/lemony 0.5.1 → 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.
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
9
  import { constants } from "node:fs";
10
10
  import { z } from "zod";
11
11
  import { createHash, randomBytes } from "node:crypto";
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"];
@@ -150,14 +154,18 @@ const openForWrite = async (path) => {
150
154
  try {
151
155
  return await open(path, WRITE | constants.O_CREAT | constants.O_EXCL);
152
156
  } catch (error) {
153
- const link = await lstat(path).catch(() => void 0);
154
- if (error.code === "EEXIST" && link?.isSymbolicLink()) throw new Error(DANGLING_WRITE_REFUSAL, { cause: error });
155
- throw 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 });
156
159
  }
160
+ return await open(path, WRITE);
157
161
  };
158
162
  //#endregion
159
163
  //#region src/fs/symlink-guard.ts
160
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) => {
161
169
  const components = /* @__PURE__ */ new Set();
162
170
  for (const relPath of relPaths) {
163
171
  let current = "";
@@ -167,16 +175,112 @@ const assertNoSymlinkTraversal = async (root, relPaths) => {
167
175
  components.add(current);
168
176
  }
169
177
  }
170
- const offenders = (await Promise.all([...components].map(async (rel) => {
178
+ return (await Promise.all([...components].map(async (rel) => {
171
179
  try {
172
180
  return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
173
181
  } catch {
174
182
  return null;
175
183
  }
176
184
  }))).filter((rel) => rel !== null);
177
- 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"));
178
185
  };
179
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
180
284
  //#region src/config/config-error.ts
181
285
  const formatConfigError = (schema, error) => {
182
286
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
@@ -240,6 +344,16 @@ const levenshtein = (a, b) => {
240
344
  return prev[b.length] ?? 0;
241
345
  };
242
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
243
357
  //#region src/config/config.schema.ts
244
358
  const pathsSchema = z.object({
245
359
  playbooks: z.string().default(PATHS_DEFAULTS.playbooks),
@@ -253,7 +367,15 @@ const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z
253
367
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
254
368
  const designTokensSchema = z.object({
255
369
  scan_extensions: z.array(z.string()).default([]),
256
- 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()
257
379
  }).strict().prefault({});
258
380
  const mergeSchema = z.object({
259
381
  checks_timeout_secs: z.int().positive().default(600),
@@ -275,24 +397,31 @@ const harnessConfigSchema = z.object({
275
397
  }).strict();
276
398
  //#endregion
277
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);
278
401
  const readHarnessConfig = async (repoRoot) => {
279
402
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
280
403
  let raw;
281
404
  try {
282
405
  raw = await readRegularFile(configPath);
283
406
  } catch (cause) {
284
- 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
+ }
285
413
  throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
286
414
  }
287
415
  let parsed;
288
416
  try {
289
417
  parsed = parse(raw);
290
418
  } catch (cause) {
291
- throw new Error(`${HARNESS_CONFIG_FILENAME} is empty or malformed.`, { cause });
419
+ throw new Error(CONFIG_MALFORMED_MESSAGE, { cause });
292
420
  }
293
- 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);
294
422
  const candidate = { ...parsed };
295
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];
296
425
  const paths = candidate.paths;
297
426
  if (paths && typeof paths === "object" && !Array.isArray(paths)) {
298
427
  const pathsCandidate = { ...paths };
@@ -309,6 +438,16 @@ const readHarnessConfig = async (repoRoot) => {
309
438
  if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
310
439
  return result.data;
311
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
+ };
312
451
  const readOptionalHarnessConfig = async (repoRoot) => {
313
452
  let present;
314
453
  try {
@@ -316,13 +455,8 @@ const readOptionalHarnessConfig = async (repoRoot) => {
316
455
  } catch (error) {
317
456
  throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
318
457
  }
319
- if (!present) return void 0;
320
- try {
321
- return await readHarnessConfig(repoRoot);
322
- } catch (error) {
323
- if (error.cause?.code === "ENOENT") throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
324
- throw error;
325
- }
458
+ if (!present && !await hasInstallBaseline(repoRoot)) return void 0;
459
+ return readHarnessConfig(repoRoot);
326
460
  };
327
461
  //#endregion
328
462
  //#region src/config/compare-version.ts
@@ -380,6 +514,34 @@ const setConfigValues = (rawYaml, updates) => {
380
514
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
381
515
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
382
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;
383
545
  return doc.toString();
384
546
  };
385
547
  const writeConfigValues = async (repoRoot, updates) => {
@@ -411,20 +573,6 @@ const pointerFrontmatterSchema = z.object({
411
573
  });
412
574
  Object.keys(pointerFrontmatterSchema.shape);
413
575
  //#endregion
414
- //#region src/paths/claude-paths.constant.ts
415
- const CLAUDE_DIR = ".claude";
416
- const STATE_DIR = join(CLAUDE_DIR, "state");
417
- const TASKS_DIR = join(STATE_DIR, "tasks");
418
- const SKILLS_DIR = join(CLAUDE_DIR, "skills");
419
- const AGENTS_DIR = join(CLAUDE_DIR, "agents");
420
- const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
421
- const COMMANDS_DIR = join(CLAUDE_DIR, "commands");
422
- const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
423
- const HARNESS_DIR = join(CLAUDE_DIR, ".harness");
424
- const BASELINE_ROOT_REL = join(HARNESS_DIR, "baseline");
425
- const SNAPSHOTS_ROOT_REL = join(HARNESS_DIR, "snapshots");
426
- const EVENTS_RELPATH = join(STATE_DIR, "events.jsonl");
427
- //#endregion
428
576
  //#region src/events/envelope.ts
429
577
  const buildEnvelope = (input) => {
430
578
  const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
@@ -537,7 +685,8 @@ const taskDoneSchema = envelopeBase.extend({
537
685
  cycle_time_h: z.number().nonnegative().finite(),
538
686
  review_rejections: z.number().int().nonnegative(),
539
687
  mode: z.enum(IMPLEMENTATION_MODES).optional(),
540
- steps: z.number().int().positive().optional()
688
+ steps: z.number().int().positive().optional(),
689
+ checkpoints: z.number().int().positive().optional()
541
690
  }).strict();
542
691
  const reviewRejectedSchema = envelopeBase.extend({
543
692
  type: z.literal("review_rejected"),
@@ -584,6 +733,16 @@ const eventSchema = z.discriminatedUnion("type", [
584
733
  stepCompletedSchema
585
734
  ]);
586
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
587
746
  //#region src/events/emit.ts
588
747
  const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
589
748
  "session_active_h",
@@ -595,6 +754,7 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
595
754
  "discovered_h",
596
755
  "step",
597
756
  "steps",
757
+ "checkpoints",
598
758
  "review_iterations"
599
759
  ]);
600
760
  const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
@@ -625,7 +785,7 @@ const parseFlags = (args) => {
625
785
  }
626
786
  return result;
627
787
  };
628
- const runEmit = async (options) => {
788
+ const prepareEmit = async (options) => {
629
789
  const { repoRoot, harnessVersion, args } = options;
630
790
  const [rawType, ...rest] = args;
631
791
  if (!rawType) throw new Error(`Usage: lemony emit <type> [--field=value …]. Types: ${EVENT_TYPES.join(", ")}.`);
@@ -652,11 +812,217 @@ const runEmit = async (options) => {
652
812
  const issues = parsed.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
653
813
  throw new Error(`Event validation failed for type "${rawType}":\n${issues}`);
654
814
  }
655
- const eventsPath = join(repoRoot, EVENTS_RELPATH);
656
- 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")}`);
657
817
  return {
658
818
  event: parsed.data,
659
- 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
660
1026
  };
661
1027
  };
662
1028
  //#endregion
@@ -1140,8 +1506,17 @@ const failureReason$1 = (result) => {
1140
1506
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
1141
1507
  };
1142
1508
  //#endregion
1143
- //#region src/design-tokens/design-tokens.constant.ts
1144
- const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
1509
+ //#region src/design-tokens/read-token-file.ts
1510
+ const readOptionalTokenFile = async (repoRoot, tokenFile) => {
1511
+ const tokenPath = join(repoRoot, tokenFile);
1512
+ try {
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 });
1518
+ }
1519
+ };
1145
1520
  const SCANNED_EXTENSIONS = [
1146
1521
  ".css",
1147
1522
  ".scss",
@@ -1182,19 +1557,6 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
1182
1557
  ".cache",
1183
1558
  ".claude"
1184
1559
  ]);
1185
- const NESTED_TOO_DEEPLY = `${DESIGN_TOKENS_FILE} nests deeper than 64 levels, the most Lemony reads.`;
1186
- //#endregion
1187
- //#region src/design-tokens/read-token-file.ts
1188
- const readOptionalTokenFile = async (repoRoot) => {
1189
- const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
1190
- try {
1191
- if (!await entryExists(tokenPath)) return void 0;
1192
- return await readRegularFile(tokenPath);
1193
- } catch (error) {
1194
- if (error.code === "ENOENT") throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: the entry is there but its target is missing (a broken symlink?).`, { cause: error });
1195
- throw new Error(`Cannot read ${DESIGN_TOKENS_FILE} at ${repoRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1196
- }
1197
- };
1198
1560
  //#endregion
1199
1561
  //#region src/design-tokens/design-tokens.model.ts
1200
1562
  const TOKEN_TIERS = [
@@ -1215,6 +1577,7 @@ const WCAG_FLOORS = {
1215
1577
  };
1216
1578
  //#endregion
1217
1579
  //#region src/design-tokens/nesting-depth.ts
1580
+ const nestedTooDeeply = (tokenFile) => `${tokenFile} nests deeper than 64 levels, the most Lemony reads.`;
1218
1581
  const exceedsNestingLimit = (value, limit = 64) => {
1219
1582
  const pending = [{
1220
1583
  node: value,
@@ -1233,7 +1596,7 @@ const exceedsNestingLimit = (value, limit = 64) => {
1233
1596
  };
1234
1597
  //#endregion
1235
1598
  //#region src/design-tokens/non-finite-number.ts
1236
- const nonFiniteNumberFaults = (value) => nonFiniteNumbers(value, []).map(({ path, number }) => `${DESIGN_TOKENS_FILE} has a number at "${path}" too large for JSON to carry: it reads as ${number}, and rewriting the file would turn it into null.`);
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.`);
1237
1600
  const nonFiniteNumbers = (value, path) => {
1238
1601
  if (typeof value === "number") return Number.isFinite(value) ? [] : [{
1239
1602
  path: path.join("."),
@@ -1251,11 +1614,15 @@ const aliasTarget = (value) => {
1251
1614
  };
1252
1615
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1253
1616
  //#endregion
1617
+ //#region src/design-tokens/resolve-token-file.ts
1618
+ const resolveTokenFile = (declared) => declared ?? "docs/design-tokens.json";
1619
+ //#endregion
1254
1620
  //#region src/design-tokens/design-tokens.ts
1255
1621
  const runValidate = async (inputs) => {
1622
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
1256
1623
  let raw;
1257
1624
  try {
1258
- raw = await readOptionalTokenFile(inputs.repoRoot);
1625
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
1259
1626
  } catch (error) {
1260
1627
  return {
1261
1628
  ok: false,
@@ -1275,7 +1642,7 @@ const runValidate = async (inputs) => {
1275
1642
  filesScanned: 0,
1276
1643
  violations: []
1277
1644
  };
1278
- const fileCheck = validateTokenFile(raw);
1645
+ const fileCheck = validateTokenFile(raw, tokenFile);
1279
1646
  if (!fileCheck.ok) return {
1280
1647
  ok: false,
1281
1648
  tokensFound: true,
@@ -1298,7 +1665,7 @@ const runValidate = async (inputs) => {
1298
1665
  };
1299
1666
  }
1300
1667
  const extensions = mergeExtensions(inputs.extraExtensions);
1301
- const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1668
+ const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions, tokenFile);
1302
1669
  return {
1303
1670
  ok: sourceViolations.violations.length === 0,
1304
1671
  tokensFound: true,
@@ -1307,14 +1674,15 @@ const runValidate = async (inputs) => {
1307
1674
  violations: sourceViolations.violations
1308
1675
  };
1309
1676
  };
1310
- const summarizeTokenFileViolations = (raw) => summarizeMessages(tokenFileViolationMessages(raw));
1311
- const tokenFileViolationMessages = (raw) => validateTokenFile(raw).violations.map((violation) => violation.message);
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);
1312
1680
  const summarizeMessages = (messages) => {
1313
1681
  const [first, ...more] = messages;
1314
1682
  if (first === void 0) return void 0;
1315
1683
  return `${first}${more.length > 0 ? ` (and ${more.length} more)` : ""}`;
1316
1684
  };
1317
- const validateTokenFile = (raw) => {
1685
+ const validateTokenFile = (raw, tokenFile) => {
1318
1686
  let parsed;
1319
1687
  try {
1320
1688
  parsed = JSON.parse(raw);
@@ -1324,7 +1692,7 @@ const validateTokenFile = (raw) => {
1324
1692
  tokenCount: 0,
1325
1693
  violations: [{
1326
1694
  kind: "invalid-json",
1327
- 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)}`
1328
1696
  }]
1329
1697
  };
1330
1698
  }
@@ -1333,7 +1701,7 @@ const validateTokenFile = (raw) => {
1333
1701
  tokenCount: 0,
1334
1702
  violations: [{
1335
1703
  kind: "malformed-token-file",
1336
- 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.`
1337
1705
  }]
1338
1706
  };
1339
1707
  if (exceedsNestingLimit(parsed)) return {
@@ -1341,11 +1709,11 @@ const validateTokenFile = (raw) => {
1341
1709
  tokenCount: 0,
1342
1710
  violations: [{
1343
1711
  kind: "malformed-token-file",
1344
- message: NESTED_TOO_DEEPLY
1712
+ message: nestedTooDeeply(tokenFile)
1345
1713
  }]
1346
1714
  };
1347
1715
  const violations = [];
1348
- for (const message of nonFiniteNumberFaults(parsed)) violations.push({
1716
+ for (const message of nonFiniteNumberFaults(parsed, tokenFile)) violations.push({
1349
1717
  kind: "malformed-token-file",
1350
1718
  message
1351
1719
  });
@@ -1409,53 +1777,160 @@ const collectAliasViolations = (tokens) => {
1409
1777
  const violations = [];
1410
1778
  for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
1411
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
+ }
1412
1787
  if (alias !== void 0 && !tokens.has(alias)) violations.push({
1413
1788
  kind: "unresolved-alias",
1414
1789
  message: `token "${path}" references "{${alias}}" in mode "${mode}", which does not exist.`
1415
1790
  });
1416
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()]);
1417
1807
  for (const [path, token] of tokens) {
1418
1808
  const alias = aliasTarget(token.value);
1419
- if (alias === void 0) continue;
1420
- if (token.tier === "primitive") {
1809
+ if (alias !== void 0 && token.tier === "primitive") {
1421
1810
  violations.push({
1422
1811
  kind: "primitive-not-literal",
1423
1812
  message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
1424
1813
  });
1425
1814
  continue;
1426
1815
  }
1427
- if (!tokens.has(alias)) {
1816
+ if (alias !== void 0 && !tokens.has(alias)) {
1428
1817
  violations.push({
1429
1818
  kind: "unresolved-alias",
1430
1819
  message: `token "${path}" references "{${alias}}", which does not exist.`
1431
1820
  });
1432
1821
  continue;
1433
1822
  }
1434
- if (aliasChainCycles(path, tokens)) violations.push({
1823
+ if (token.tier === "primitive") continue;
1824
+ if (loopsInBase.has(path)) violations.push({
1435
1825
  kind: "cyclic-alias",
1436
1826
  message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1437
1827
  });
1438
1828
  }
1439
- for (const [path, token] of tokens) for (const [mode, value] of Object.entries(token.modes)) {
1440
- if (aliasTarget(value) === void 0) continue;
1441
- if (aliasChainCycles(path, tokens, mode)) violations.push({
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({
1442
1863
  kind: "cyclic-alias",
1443
1864
  message: `token "${path}" has an alias chain in mode "${mode}" that never resolves to a literal value (alias cycle).`
1444
1865
  });
1445
1866
  }
1446
1867
  return violations;
1447
1868
  };
1448
- const aliasChainCycles = (start, tokens, mode) => {
1449
- const seen = /* @__PURE__ */ new Set();
1450
- let current = start;
1451
- while (current !== void 0) {
1452
- if (seen.has(current)) return true;
1453
- seen.add(current);
1454
- const token = tokens.get(current);
1455
- if (token === void 0) return false;
1456
- current = aliasTarget(mode !== void 0 && Object.hasOwn(token.modes, mode) ? token.modes[mode] : 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);
1457
1877
  }
1458
- 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));
1459
1934
  };
1460
1935
  const mergeExtensions = (extra) => {
1461
1936
  const set = new Set(SCANNED_EXTENSIONS);
@@ -1483,7 +1958,7 @@ const describeUnreadableDir = async (dir) => {
1483
1958
  return `can be listed but not searched (${codeOf(error)})`;
1484
1959
  }
1485
1960
  };
1486
- const scanSource = async (repoRoot, scanRoot, extensions) => {
1961
+ const scanSource = async (repoRoot, scanRoot, extensions, tokenFile) => {
1487
1962
  if (!await pathExists(scanRoot)) return {
1488
1963
  filesScanned: 0,
1489
1964
  violations: []
@@ -1501,7 +1976,7 @@ const scanSource = async (repoRoot, scanRoot, extensions) => {
1501
1976
  if (content === void 0) continue;
1502
1977
  filesScanned += 1;
1503
1978
  const relPath = relative(repoRoot, file).split(sep).join("/");
1504
- violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
1979
+ violations.push(...scanFile(relPath, extname(file).toLowerCase(), content, tokenFile));
1505
1980
  }
1506
1981
  return {
1507
1982
  filesScanned,
@@ -1532,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]{
1532
2007
  const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
1533
2008
  const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
1534
2009
  const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
1535
- const scanFile = (relPath, ext, content) => {
2010
+ const scanFile = (relPath, ext, content, tokenFile) => {
1536
2011
  const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
1537
2012
  const violations = [];
1538
2013
  content.split("\n").forEach((line, index) => {
@@ -1540,13 +2015,13 @@ const scanFile = (relPath, ext, content) => {
1540
2015
  const lineNo = index + 1;
1541
2016
  if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
1542
2017
  kind: "hardcoded-color",
1543
- message: `raw color literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
2018
+ message: `raw color literal — reference a token from ${tokenFile} instead.`,
1544
2019
  file: relPath,
1545
2020
  line: lineNo
1546
2021
  });
1547
2022
  if (isStylesheet && hasRawDimension(line)) violations.push({
1548
2023
  kind: "hardcoded-dimension",
1549
- message: `raw dimension literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
2024
+ message: `raw dimension literal — reference a token from ${tokenFile} instead.`,
1550
2025
  file: relPath,
1551
2026
  line: lineNo
1552
2027
  });
@@ -1694,7 +2169,11 @@ const contrastRatio = (foreground, background) => {
1694
2169
  const l2 = relativeLuminance(bg);
1695
2170
  const light = Math.max(l1, l2);
1696
2171
  const dark = Math.min(l1, l2);
1697
- return Math.round((light + .05) / (dark + .05) * 100) / 100;
2172
+ return (light + .05) / (dark + .05);
2173
+ };
2174
+ const truncateRatio = (ratio) => {
2175
+ const hundredths = Math.floor(ratio * 100);
2176
+ return hundredths / 100 > ratio ? (hundredths - 1) / 100 : hundredths / 100;
1698
2177
  };
1699
2178
  //#endregion
1700
2179
  //#region src/design-tokens/contrast.model.ts
@@ -1706,10 +2185,11 @@ const CONTRAST_LEVELS = [
1706
2185
  //#endregion
1707
2186
  //#region src/design-tokens/contrast.ts
1708
2187
  const runContrast = async (inputs) => {
2188
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
1709
2189
  let raw;
1710
2190
  let unreadable;
1711
2191
  try {
1712
- raw = await readOptionalTokenFile(inputs.repoRoot);
2192
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
1713
2193
  } catch (error) {
1714
2194
  unreadable = error instanceof Error ? error.message : String(error);
1715
2195
  }
@@ -1732,18 +2212,18 @@ const runContrast = async (inputs) => {
1732
2212
  return withVerify({
1733
2213
  ...emptyResult$1(true),
1734
2214
  ok: false,
1735
- problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
2215
+ problems: [`${tokenFile} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1736
2216
  });
1737
2217
  }
1738
2218
  if (!isRecord(parsed)) return withVerify({
1739
2219
  ...emptyResult$1(true),
1740
2220
  ok: false,
1741
- problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
2221
+ problems: [`${tokenFile} must be a JSON object — run \`design-tokens validate\` first.`]
1742
2222
  });
1743
2223
  if (exceedsNestingLimit(parsed)) return withVerify({
1744
2224
  ...emptyResult$1(true),
1745
2225
  ok: false,
1746
- problems: [`${NESTED_TOO_DEEPLY} Run \`design-tokens validate\` first.`]
2226
+ problems: [`${nestedTooDeeply(tokenFile)} Run \`design-tokens validate\` first.`]
1747
2227
  });
1748
2228
  const tokens = collectTokens(parsed);
1749
2229
  const problems = [];
@@ -2025,7 +2505,7 @@ const makeRow = (spec, mode, foreground, background) => {
2025
2505
  ...spec.over === void 0 ? {} : { over: spec.over },
2026
2506
  mode,
2027
2507
  level: spec.level,
2028
- ratio,
2508
+ ratio: truncateRatio(ratio),
2029
2509
  floor,
2030
2510
  passes: ratio >= floor,
2031
2511
  source: spec.source
@@ -2040,7 +2520,7 @@ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
2040
2520
  let value = token.value;
2041
2521
  if (mode !== "base") {
2042
2522
  const modes = readExtension(token.node, MODES_EXTENSION);
2043
- if (isRecord(modes) && modes[mode] !== void 0) value = modes[mode];
2523
+ if (isRecord(modes) && Object.hasOwn(modes, mode)) value = modes[mode];
2044
2524
  }
2045
2525
  const alias = aliasTarget(value);
2046
2526
  if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
@@ -2076,7 +2556,7 @@ const NEUTRAL_VALUE_OR_REF = "Each variable carries a string \"value\" (a litera
2076
2556
  const NEUTRAL_NAME_SHAPE = "A name is dot-separated segments, like \"primitive.color.brand\" — fix the adapter that wrote the file.";
2077
2557
  const VALIDATE_HINT = "Run `lemony design-tokens validate` for the full list.";
2078
2558
  const IMPORT_INVALID_HINT = "Change the `--only` slice (add what it references), or fix the file first; nothing was written.";
2079
- const IMPORT_NOT_CARRIED_HINT = "add the token in the design tool (or in docs/design-tokens.json) first, or leave out what references it; nothing was written.";
2559
+ const IMPORT_FAULT_CLAUSE = "Fix the variables named above in the design tool, or leave them out with `--only`";
2080
2560
  const IMPORT_PREVIEW_INVALID_HINT = "`--apply` refuses any slice that leaves it failing: include what repairs it, or fix the file first.";
2081
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.";
2082
2562
  //#endregion
@@ -2152,7 +2632,7 @@ const ownUnsyncedReason = (node) => {
2152
2632
  for (const [name, value] of Object.entries(modes)) {
2153
2633
  if (name.trim().length === 0) return "a mode with a blank name";
2154
2634
  if (name === "__proto__") return "a mode named \"__proto__\"";
2155
- if (!isScalar(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
2635
+ if (!isScalar$1(value)) return `mode "${name}" holding neither text, a number nor a boolean`;
2156
2636
  }
2157
2637
  };
2158
2638
  const aliasTargetsOf = (node) => {
@@ -2164,7 +2644,7 @@ const modesExtension = (node) => {
2164
2644
  return isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
2165
2645
  };
2166
2646
  const isCompositeValue = (value) => typeof value === "object" && value !== null;
2167
- const isScalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
2647
+ const isScalar$1 = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
2168
2648
  const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
2169
2649
  const toVariable$1 = (name, node) => {
2170
2650
  const type = typeof node["$type"] === "string" ? node["$type"] : "";
@@ -2189,7 +2669,7 @@ const readModes$1 = (node) => {
2189
2669
  const modes = modesExtension(node);
2190
2670
  if (!isRecord(modes)) return void 0;
2191
2671
  const out = {};
2192
- for (const [name, value] of Object.entries(modes)) if (isScalar(value)) out[name] = String(value);
2672
+ for (const [name, value] of Object.entries(modes)) if (isScalar$1(value)) out[name] = String(value);
2193
2673
  return Object.keys(out).length > 0 ? out : void 0;
2194
2674
  };
2195
2675
  const landingPath = (variable, known) => {
@@ -2197,7 +2677,7 @@ const landingPath = (variable, known) => {
2197
2677
  if (KNOWN_TIERS.has(first)) return variable.name;
2198
2678
  const existing = existingTierPath(variable.name, known);
2199
2679
  if (existing !== void 0) return existing;
2200
- return `${variable.ref === void 0 ? "primitive" : "semantic"}.${variable.name}`;
2680
+ return `${variable.ref !== void 0 || Object.values(variable.modes ?? {}).some((modeValue) => aliasTarget(modeValue) !== void 0) ? "semantic" : "primitive"}.${variable.name}`;
2201
2681
  };
2202
2682
  const existingTierPath = (bare, known) => {
2203
2683
  const matches = TOKEN_TIERS.map((tier) => `${tier}.${bare}`).filter((path) => known.has(path));
@@ -2286,6 +2766,7 @@ const diffNeutralAgainstJson = (neutral, parsed) => {
2286
2766
  }
2287
2767
  return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2288
2768
  };
2769
+ const ownMode = (modes, mode) => Object.hasOwn(modes, mode) ? modes[mode] : void 0;
2289
2770
  const changeDetail = (existing, incoming) => {
2290
2771
  const detail = {};
2291
2772
  if (existing.type !== incoming.type) detail.typeChange = {
@@ -2294,11 +2775,15 @@ const changeDetail = (existing, incoming) => {
2294
2775
  };
2295
2776
  const before = existing.modes ?? {};
2296
2777
  const after = incoming.modes ?? {};
2297
- const modeChanges = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].toSorted().filter((mode) => before[mode] !== after[mode]).map((mode) => ({
2298
- mode,
2299
- ...before[mode] !== void 0 ? { before: before[mode] } : {},
2300
- ...after[mode] !== void 0 ? { after: after[mode] } : {}
2301
- }));
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
+ });
2302
2787
  if (modeChanges.length > 0) detail.modeChanges = modeChanges;
2303
2788
  return detail;
2304
2789
  };
@@ -2469,15 +2954,15 @@ const jsonKind = (value) => {
2469
2954
  const kind = typeof value;
2470
2955
  return kind === "object" ? "an object" : `a ${kind}`;
2471
2956
  };
2472
- const readJsonObject = (raw) => {
2957
+ const readJsonObject = (raw, tokenFile) => {
2473
2958
  let parsed;
2474
2959
  try {
2475
2960
  parsed = JSON.parse(raw);
2476
2961
  } catch (error) {
2477
- 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 });
2478
2963
  }
2479
- if (!isRecord(parsed)) throw new Error(`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`);
2480
- if (exceedsNestingLimit(parsed)) throw new Error(`${NESTED_TOO_DEEPLY} 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.`);
2481
2966
  return parsed;
2482
2967
  };
2483
2968
  const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
@@ -2509,13 +2994,14 @@ const runImport = async (inputs) => {
2509
2994
  ...empty,
2510
2995
  problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
2511
2996
  };
2997
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
2512
2998
  let tokensFound = false;
2513
2999
  let raw;
2514
3000
  let parsed;
2515
3001
  try {
2516
- raw = await readOptionalTokenFile(inputs.repoRoot);
3002
+ raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
2517
3003
  tokensFound = raw !== void 0;
2518
- parsed = raw === void 0 ? {} : readJsonObject(raw);
3004
+ parsed = raw === void 0 ? {} : readJsonObject(raw, tokenFile);
2519
3005
  } catch (error) {
2520
3006
  return {
2521
3007
  ...empty,
@@ -2525,12 +3011,12 @@ const runImport = async (inputs) => {
2525
3011
  }
2526
3012
  const changes = diffNeutralAgainstJson(neutral, parsed);
2527
3013
  if (!inputs.apply) {
2528
- const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw));
3014
+ const faults = summarizeMessages(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
2529
3015
  return {
2530
3016
  ...empty,
2531
3017
  tokensFound,
2532
3018
  changes,
2533
- warnings: faults === void 0 ? [] : [`${DESIGN_TOKENS_FILE} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
3019
+ warnings: faults === void 0 ? [] : [`${tokenFile} already fails validation: ${faults} ${IMPORT_PREVIEW_INVALID_HINT} ${VALIDATE_HINT}`]
2534
3020
  };
2535
3021
  }
2536
3022
  const unmatched = (inputs.only ?? []).filter((path) => !changes.some((change) => change.path === path));
@@ -2547,28 +3033,30 @@ const runImport = async (inputs) => {
2547
3033
  tokensFound,
2548
3034
  changes,
2549
3035
  refused: true,
2550
- problems: [`not applied — with this import, ${DESIGN_TOKENS_FILE} would fail validation: ${NESTED_TOO_DEEPLY} ${IMPORT_TOO_DEEP_HINT}`]
3036
+ problems: [`not applied — with this import, ${tokenFile} would fail validation: ${nestedTooDeeply(tokenFile)} ${IMPORT_TOO_DEEP_HINT}`]
2551
3037
  };
2552
3038
  const text = writeJsonFile(merged);
2553
- const mergedFaults = [...nonFiniteNumberFaults(merged), ...tokenFileViolationMessages(text)];
3039
+ const mergedViolations = tokenFileViolations(text, tokenFile);
3040
+ const mergedFaults = [...nonFiniteNumberFaults(merged, tokenFile), ...mergedViolations.map((violation) => violation.message)];
2554
3041
  if (mergedFaults.length > 0) {
2555
- const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw));
3042
+ const onDisk = new Set(raw === void 0 ? [] : tokenFileViolationMessages(raw, tokenFile));
2556
3043
  const introduced = mergedFaults.filter((fault) => !onDisk.has(fault));
2557
3044
  const landings = new Set(changes.map((change) => change.path));
2558
- const dangling = danglingAliasTargets(merged, written);
3045
+ const dangling = danglingAliasTargets(merged, written.filter((path) => !path.startsWith("primitive.")));
2559
3046
  const leftBehind = dangling.filter((target) => landings.has(target));
2560
3047
  const notCarried = dangling.filter((target) => !landings.has(target));
2561
- const hint = notCarried.length === 0 ? IMPORT_INVALID_HINT : `${leftBehind.length > 0 ? `Add ${leftBehind.join(", ")} to the \`--only\` slice. ` : ""}The neutral file carries no variable that lands at ${notCarried.join(", ")}, so no \`--only\` slice can supply ${notCarried.length === 1 ? "it" : "them"}: ${IMPORT_NOT_CARRIED_HINT}`;
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);
2562
3050
  return {
2563
3051
  ...empty,
2564
3052
  tokensFound,
2565
3053
  changes,
2566
3054
  refused: true,
2567
- problems: [introduced.length > 0 ? `not applied — with this import, ${DESIGN_TOKENS_FILE} would fail validation: ${summarizeMessages(introduced)} ${hint}` : `not applied — ${DESIGN_TOKENS_FILE} already fails validation and this import does not repair it: ${summarizeMessages(mergedFaults)} ${VALIDATE_HINT}`]
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}`]
2568
3056
  };
2569
3057
  }
2570
3058
  if (written.length > 0) {
2571
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
3059
+ const tokenPath = join(inputs.repoRoot, tokenFile);
2572
3060
  await mkdir(dirname(tokenPath), { recursive: true });
2573
3061
  await writeFile(tokenPath, text);
2574
3062
  }
@@ -2584,6 +3072,14 @@ const runImport = async (inputs) => {
2584
3072
  warnings: []
2585
3073
  };
2586
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
+ };
2587
3083
  const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
2588
3084
  //#endregion
2589
3085
  //#region src/design-tokens/drift.ts
@@ -2640,16 +3136,17 @@ const runExport = async (inputs) => {
2640
3136
  recorded: false,
2641
3137
  problems: []
2642
3138
  };
3139
+ const tokenFile = resolveTokenFile(inputs.tokenFile);
2643
3140
  let parsed;
2644
3141
  try {
2645
- const raw = await readOptionalTokenFile(inputs.repoRoot);
3142
+ const raw = await readOptionalTokenFile(inputs.repoRoot, tokenFile);
2646
3143
  if (raw === void 0) return empty;
2647
- parsed = readJsonObject(raw);
2648
- const invalid = summarizeTokenFileViolations(raw);
3144
+ parsed = readJsonObject(raw, tokenFile);
3145
+ const invalid = summarizeTokenFileViolations(raw, tokenFile);
2649
3146
  if (invalid !== void 0) return {
2650
3147
  ...empty,
2651
3148
  tokensFound: true,
2652
- problems: [`${DESIGN_TOKENS_FILE} fails validation: ${invalid} ${VALIDATE_HINT}`]
3149
+ problems: [`${tokenFile} fails validation: ${invalid} ${VALIDATE_HINT}`]
2653
3150
  };
2654
3151
  } catch (error) {
2655
3152
  return {
@@ -2662,7 +3159,7 @@ const runExport = async (inputs) => {
2662
3159
  if (!binding) return {
2663
3160
  ...empty,
2664
3161
  tokensFound: true,
2665
- 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.`]
2666
3163
  };
2667
3164
  const hash = projectionHash(parsed);
2668
3165
  let toolState;
@@ -2681,11 +3178,11 @@ const runExport = async (inputs) => {
2681
3178
  if (inputs.outPath !== void 0) {
2682
3179
  const projection = dtcgToNeutral(parsed);
2683
3180
  projection.provider = binding.provider;
2684
- if (await sameEntry(inputs.outPath, join(inputs.repoRoot, "docs/design-tokens.json"))) return {
3181
+ if (await sameEntry(inputs.outPath, join(inputs.repoRoot, tokenFile))) return {
2685
3182
  ...empty,
2686
3183
  tokensFound: true,
2687
3184
  hash,
2688
- problems: [`Cannot write the projection to ${inputs.outPath}: it is ${DESIGN_TOKENS_FILE} itself, which the projection would replace. Name another path.`]
3185
+ problems: [`Cannot write the projection to ${inputs.outPath}: it is ${tokenFile} itself, which the projection would replace. Name another path.`]
2689
3186
  };
2690
3187
  try {
2691
3188
  await writeRegularFile(inputs.outPath, writeNeutralFile(projection));
@@ -2700,7 +3197,7 @@ const runExport = async (inputs) => {
2700
3197
  }
2701
3198
  if (inputs.record) {
2702
3199
  const stamped = writeJsonFile(stampBaseline(parsed, hash));
2703
- const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
3200
+ const tokenPath = join(inputs.repoRoot, tokenFile);
2704
3201
  await writeFile(tokenPath, stamped);
2705
3202
  return {
2706
3203
  tokensFound: true,
@@ -2735,6 +3232,51 @@ const sameEntry = async (a, b) => {
2735
3232
  };
2736
3233
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2737
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
2738
3280
  //#region src/review-ledger/review-ledger.constant.ts
2739
3281
  const REVIEW_LEDGER_DIRNAME = "review-ledger";
2740
3282
  const FULL_PASS_FILENAME = "full-pass.json";
@@ -2745,18 +3287,29 @@ const SPEC_SIDE_KINDS = [
2745
3287
  "group-missing",
2746
3288
  "group-empty",
2747
3289
  "duplicate-group-index",
3290
+ "duplicate-task-id",
2748
3291
  "orphan-task",
2749
3292
  "malformed-risk-marker",
2750
3293
  "malformed-task-refs",
3294
+ "malformed-task-line",
3295
+ "malformed-group-header",
2751
3296
  "unknown-risk-class",
2752
3297
  "dangling-requirement-ref",
2753
3298
  "unticked-completed-task"
2754
3299
  ];
2755
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*/;
2756
3304
  const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2757
3305
  const RISK_MARKER_ALL = /\[risk:/gi;
2758
3306
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2759
- 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/;
2760
3313
  const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2761
3314
  const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2762
3315
  const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
@@ -2782,42 +3335,66 @@ const NOT_APPLICABLE_REASONS = [
2782
3335
  ];
2783
3336
  //#endregion
2784
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 = /^ */;
2785
3349
  const parseTasksSpec = (text) => {
2786
- const lines = text.split(/\r?\n/);
2787
- 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");
2788
3355
  const groups = [];
2789
3356
  const orphanTaskIds = [];
2790
3357
  const malformedRiskHeaders = [];
2791
3358
  const malformedTaskRefs = [];
3359
+ const taskIdGroups = /* @__PURE__ */ new Map();
3360
+ const record = (id, group) => {
3361
+ taskIdGroups.set(id, [...taskIdGroups.get(id) ?? [], group]);
3362
+ };
2792
3363
  let current;
2793
- for (let index = 0; index < lines.length; index += 1) {
2794
- const line = lines[index] ?? "";
2795
- const header = GROUP_HEADER.exec(line);
2796
- if (header) {
2797
- const risk = parseRiskMarker(line);
2798
- 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());
2799
3369
  current = {
2800
- index: Number(header[1]),
2801
- header: line.trim(),
3370
+ index: Number(GROUP_HEADER.exec(entry.line)?.[1]),
3371
+ header: entry.line.trim(),
2802
3372
  riskClasses: risk.riskClasses,
2803
3373
  unknownRiskClasses: risk.unknownRiskClasses,
2804
3374
  tasks: []
2805
3375
  };
2806
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);
2807
3387
  continue;
2808
3388
  }
2809
- const match = TASK_LINE.exec(line);
2810
- if (!match) continue;
2811
- const blockEnd = taskBlockEnd(lines, index);
2812
- const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2813
- index = blockEnd - 1;
2814
- const task = parseTaskBlock(match[2] ?? "", (match[1] ?? " ") !== " ", parts);
3389
+ const task = parseTaskBlock(id, entry.ticked, entry.parts, entry.refInCode);
2815
3390
  if (task.malformedRefs) malformedTaskRefs.push(task.id);
2816
3391
  if (current) {
3392
+ record(id, current.index);
2817
3393
  current.tasks.push(task.task);
2818
3394
  continue;
2819
3395
  }
2820
3396
  if (grouped) {
3397
+ record(id, null);
2821
3398
  orphanTaskIds.push(task.id);
2822
3399
  continue;
2823
3400
  }
@@ -2828,6 +3405,7 @@ const parseTasksSpec = (text) => {
2828
3405
  unknownRiskClasses: [],
2829
3406
  tasks: [task.task]
2830
3407
  });
3408
+ record(id, groups.length);
2831
3409
  }
2832
3410
  const seen = /* @__PURE__ */ new Set();
2833
3411
  const duplicateIndexes = [];
@@ -2839,36 +3417,298 @@ const parseTasksSpec = (text) => {
2839
3417
  groups,
2840
3418
  orphanTaskIds,
2841
3419
  duplicateIndexes,
3420
+ duplicateTaskIds: [...taskIdGroups].filter(([, copies]) => copies.length > 1).map(([id, copies]) => ({
3421
+ id,
3422
+ groups: copies
3423
+ })),
2842
3424
  malformedRiskHeaders,
2843
- 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
+ });
2844
3454
  };
2845
- };
2846
- const taskBlockEnd = (lines, start) => {
2847
- let end = start + 1;
2848
- while (end < lines.length) {
2849
- const line = lines[end] ?? "";
2850
- if (line.trim() === "" || !/^\s/.test(line) || TASK_LINE.test(line) || GROUP_HEADER.test(line)) break;
2851
- end += 1;
2852
- }
2853
- return end;
2854
- };
2855
- const parseTaskBlock = (id, ticked, parts) => {
2856
- const block = parts.join(" ");
2857
- const opener = refsOpener(parts, block);
2858
- const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
2859
- const requirementRefs = refs ? (refs[1] ?? "").split(",").map((ref) => ref.trim()).filter(Boolean) : [];
2860
- return {
2861
- id,
2862
- task: {
2863
- id,
2864
- ticked,
2865
- requirementRefs: [...new Set(requirementRefs)]
2866
- },
2867
- malformedRefs: refs === null && hasRefLookalike(parts)
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
+ });
2868
3476
  };
2869
- };
2870
- const declarationAt = (parts, block, opener) => {
2871
- const candidate = TASK_REFS.exec(block.slice(opener));
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
+ };
3575
+ }
3576
+ };
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) => {
3696
+ const block = parts.join(" ");
3697
+ const opener = refsOpener(parts, block);
3698
+ const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
3699
+ const requirementRefs = refs ? (refs[1] ?? "").split(",").map((ref) => ref.trim()).filter(Boolean) : [];
3700
+ return {
3701
+ id,
3702
+ task: {
3703
+ id,
3704
+ ticked,
3705
+ requirementRefs: [...new Set(requirementRefs)]
3706
+ },
3707
+ malformedRefs: refs === null && (refInCode || hasRefLookalike(parts))
3708
+ };
3709
+ };
3710
+ const declarationAt = (parts, block, opener) => {
3711
+ const candidate = TASK_REFS.exec(block.slice(opener));
2872
3712
  if (!candidate) return null;
2873
3713
  return declarationClosesLine(parts, opener + candidate[0].length) ? candidate : null;
2874
3714
  };
@@ -2946,9 +3786,11 @@ const parseRiskMarker = (line) => {
2946
3786
  };
2947
3787
  };
2948
3788
  const parseRequirementIds = (text) => {
3789
+ const lines = text.split(LINE_BREAK).map((line) => line.replace(LINE_BOM, ""));
2949
3790
  const ids = [];
2950
- for (const line of text.split(/\r?\n/)) {
2951
- 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];
2952
3794
  if (id && !ids.includes(id)) ids.push(id);
2953
3795
  }
2954
3796
  return ids;
@@ -3042,7 +3884,7 @@ const runLedgerValidate = async (inputs) => {
3042
3884
  });
3043
3885
  return result;
3044
3886
  }
3045
- const spec = parseTasksSpec(await readFile(join(inputs.repoRoot, tasksRel), "utf8"));
3887
+ const spec = parsedFrom(tasksRel, await readFile(join(inputs.repoRoot, tasksRel), "utf8"), parseTasksSpec);
3046
3888
  for (const index of spec.duplicateIndexes) problems.push({
3047
3889
  kind: "duplicate-group-index",
3048
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.`,
@@ -3052,14 +3894,32 @@ const runLedgerValidate = async (inputs) => {
3052
3894
  kind: "orphan-task",
3053
3895
  message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
3054
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
+ }
3055
3905
  for (const header of spec.malformedRiskHeaders) problems.push({
3056
3906
  kind: "malformed-risk-marker",
3057
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.`,
3058
3908
  subject: header
3059
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
+ });
3060
3920
  for (const id of spec.malformedTaskRefs) problems.push({
3061
3921
  kind: "malformed-task-refs",
3062
- 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.`,
3063
3923
  subject: id
3064
3924
  });
3065
3925
  const groups = resolveGroups(spec.groups, address, problems);
@@ -3100,6 +3960,23 @@ const runLedgerValidate = async (inputs) => {
3100
3960
  return result;
3101
3961
  };
3102
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
+ };
3103
3980
  const resolveGroups = (groups, address, problems) => {
3104
3981
  if (address.kind === "full-pass") {
3105
3982
  if (groups.length > 0) return groups;
@@ -3137,7 +4014,7 @@ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
3137
4014
  });
3138
4015
  return referenced;
3139
4016
  }
3140
- const declared = parseRequirementIds(await readFile(requirementsPath, "utf8"));
4017
+ const declared = parsedFrom(requirementsPath, await readFile(requirementsPath, "utf8"), parseRequirementIds);
3141
4018
  const dangling = referenced.filter((id) => !declared.includes(id));
3142
4019
  for (const id of dangling) problems.push({
3143
4020
  kind: "dangling-requirement-ref",
@@ -3298,6 +4175,13 @@ const checkCriteria = (required, dangling, ledger, problems) => {
3298
4175
  present
3299
4176
  };
3300
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
+ };
3301
4185
  //#endregion
3302
4186
  //#region src/spinoff/spinoff.constant.ts
3303
4187
  const MANAGED_LABEL = "harness:managed";
@@ -3433,7 +4317,8 @@ const FIELD_AXIS = {
3433
4317
  cycle_time_h: "metric",
3434
4318
  review_rejections: "metric",
3435
4319
  mode: "internal-enum",
3436
- steps: "metric"
4320
+ steps: "metric",
4321
+ checkpoints: "metric"
3437
4322
  },
3438
4323
  review_rejected: {
3439
4324
  ...ENVELOPE_AXIS,
@@ -4224,9 +5109,215 @@ const renderRoleSkills = (selected, role) => {
4224
5109
  return groups.length > 0 ? groups.join("\n\n") : "_None._";
4225
5110
  };
4226
5111
  //#endregion
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";
5116
+ //#endregion
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);
5120
+ //#endregion
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
+ }
5139
+ }
5140
+ return {
5141
+ merged: out.join("\n"),
5142
+ conflicted
5143
+ };
5144
+ };
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
+ }
5165
+ };
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
4227
5292
  //#region src/doctor/cli-bin.constant.ts
4228
5293
  const LEMONY_BIN = "lemony";
4229
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
4230
5321
  //#region src/doctor/resolve-cli.ts
4231
5322
  const isExecutable = async (path) => {
4232
5323
  try {
@@ -4256,6 +5347,8 @@ const runDoctor = async (deps) => {
4256
5347
  const { repoRoot } = deps;
4257
5348
  const checks = [];
4258
5349
  let config = null;
5350
+ let configSkip = "";
5351
+ let configState = "loaded";
4259
5352
  try {
4260
5353
  config = await readHarnessConfig(repoRoot);
4261
5354
  checks.push({
@@ -4264,33 +5357,122 @@ const runDoctor = async (deps) => {
4264
5357
  detail: `${HARNESS_CONFIG_FILENAME} is valid.`
4265
5358
  });
4266
5359
  } catch (error) {
5360
+ const fault = await configLoadFault(repoRoot, error);
5361
+ configSkip = fault.skip;
5362
+ configState = fault.state;
4267
5363
  checks.push({
4268
5364
  name: "config",
4269
5365
  status: "fail",
4270
5366
  detail: error.message,
4271
- remediation: `Fix ${HARNESS_CONFIG_FILENAME} (the error above names the key), then re-run \`lemony doctor\`.`
5367
+ remediation: fault.remediation
4272
5368
  });
4273
5369
  }
4274
- checks.push(await checkLabels(deps, config));
4275
- checks.push(await checkHooks(repoRoot));
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));
4276
5375
  checks.push(await checkGitUser(deps));
4277
5376
  checks.push(await checkGhAuth(deps, config));
4278
- checks.push(checkVersionPin(deps, config));
5377
+ checks.push(checkVersionPin(deps, config, configSkip));
4279
5378
  checks.push(await checkCliResolution(deps));
4280
- checks.push(await checkCapabilities(deps, config));
4281
- checks.push(await checkDesignToolDrift(deps));
4282
- checks.push(await checkHookLib(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));
4283
5383
  return {
4284
5384
  checks,
4285
5385
  ok: checks.every((check) => check.status !== "fail")
4286
5386
  };
4287
5387
  };
4288
- const checkLabels = async (deps, config) => {
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) => {
4289
5471
  const name = "labels";
4290
5472
  if (!config) return {
4291
5473
  name,
4292
5474
  status: "warn",
4293
- detail: "Skipped — config did not validate."
5475
+ detail: configSkip
4294
5476
  };
4295
5477
  const slug = config.task_storage.repo;
4296
5478
  if (slug === "OWNER/REPO") return {
@@ -4331,8 +5513,9 @@ const checkLabels = async (deps, config) => {
4331
5513
  remediation: "Run `lemony repair` to reconcile the labels."
4332
5514
  };
4333
5515
  };
4334
- const checkHooks = async (repoRoot) => {
5516
+ const checkHooks = async (repoRoot, restore) => {
4335
5517
  const name = "hooks";
5518
+ const remedy = (steps) => restore.state === "broken" ? `${FIX_CONFIG_FIRST}.` : `${capitalize(steps)}.`;
4336
5519
  const settingsPath = join(repoRoot, SETTINGS_FILE);
4337
5520
  let raw;
4338
5521
  try {
@@ -4343,19 +5526,19 @@ const checkHooks = async (repoRoot) => {
4343
5526
  name,
4344
5527
  status: "fail",
4345
5528
  detail: ".claude/settings.json is a symlink whose target is missing.",
4346
- remediation: "Remove the symlink, then run `lemony repair`."
5529
+ remediation: remedy(`remove the symlink, then ${verbStep(restore, [SETTINGS_FILE])}`)
4347
5530
  };
4348
5531
  if (code === "ENOENT") return {
4349
5532
  name,
4350
5533
  status: "fail",
4351
5534
  detail: ".claude/settings.json not found.",
4352
- remediation: "Run `lemony repair`."
5535
+ remediation: remedy(verbStep(restore))
4353
5536
  };
4354
5537
  return {
4355
5538
  name,
4356
5539
  status: "fail",
4357
5540
  detail: `Cannot read .claude/settings.json: ${code === void 0 ? error.message : `${code}.`}`,
4358
- remediation: "Replace it with a readable file (or remove it), then run `lemony repair`."
5541
+ remediation: remedy(`replace it with a regular, readable file (or remove it), then ${verbStep(restore, [SETTINGS_FILE])}`)
4359
5542
  };
4360
5543
  }
4361
5544
  let commands;
@@ -4367,7 +5550,7 @@ const checkHooks = async (repoRoot) => {
4367
5550
  name,
4368
5551
  status: "fail",
4369
5552
  detail: ".claude/settings.json is not valid JSON.",
4370
- remediation: "Run `lemony repair`."
5553
+ remediation: remedy(`fix the JSON or delete the file, then ${verbStep(restore)}`)
4371
5554
  };
4372
5555
  }
4373
5556
  const missing = VENDOR_HOOK_COMMANDS.filter((want) => !commands.some((cmd) => cmd === want || cmd.endsWith(`/${want}`)));
@@ -4380,7 +5563,7 @@ const checkHooks = async (repoRoot) => {
4380
5563
  name,
4381
5564
  status: "warn",
4382
5565
  detail: `Missing vendor hook wiring: ${missing.join(", ")}.`,
4383
- remediation: "Run `lemony repair`."
5566
+ remediation: remedy(verbStep(restore))
4384
5567
  };
4385
5568
  };
4386
5569
  const checkGitUser = async (deps) => {
@@ -4417,12 +5600,12 @@ const checkGhAuth = async (deps, config) => {
4417
5600
  remediation: "Run `gh auth login`."
4418
5601
  };
4419
5602
  };
4420
- const checkVersionPin = (deps, config) => {
5603
+ const checkVersionPin = (deps, config, configSkip) => {
4421
5604
  const name = "version-pin";
4422
5605
  if (!config) return {
4423
5606
  name,
4424
5607
  status: "warn",
4425
- detail: "Skipped — config did not validate."
5608
+ detail: configSkip
4426
5609
  };
4427
5610
  if (config.vendor_version === deps.installedVersion) return {
4428
5611
  name,
@@ -4479,12 +5662,12 @@ const checkCliResolution = async (deps) => {
4479
5662
  remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
4480
5663
  };
4481
5664
  };
4482
- const checkCapabilities = async (deps, config) => {
5665
+ const checkCapabilities = async (deps, config, configSkip) => {
4483
5666
  const name = "capabilities";
4484
5667
  if (!config) return {
4485
5668
  name,
4486
5669
  status: "warn",
4487
- detail: "Skipped — config did not validate."
5670
+ detail: configSkip
4488
5671
  };
4489
5672
  let latent;
4490
5673
  try {
@@ -4511,11 +5694,17 @@ const checkCapabilities = async (deps, config) => {
4511
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)."
4512
5695
  };
4513
5696
  };
4514
- const checkDesignToolDrift = async (deps) => {
5697
+ const checkDesignToolDrift = async (deps, config, configSkip) => {
4515
5698
  const name = "design-tool-drift";
5699
+ if (!config) return {
5700
+ name,
5701
+ status: "warn",
5702
+ detail: configSkip
5703
+ };
5704
+ const tokenFile = resolveTokenFile(config.design_tokens.file);
4516
5705
  let raw;
4517
5706
  try {
4518
- raw = await readOptionalTokenFile(deps.repoRoot);
5707
+ raw = await readOptionalTokenFile(deps.repoRoot, tokenFile);
4519
5708
  } catch (error) {
4520
5709
  return {
4521
5710
  name,
@@ -4527,7 +5716,7 @@ const checkDesignToolDrift = async (deps) => {
4527
5716
  if (raw === void 0) return {
4528
5717
  name,
4529
5718
  status: "info",
4530
- detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
5719
+ detail: `No ${tokenFile} — token sync not in use.`
4531
5720
  };
4532
5721
  let parsed;
4533
5722
  try {
@@ -4536,21 +5725,21 @@ const checkDesignToolDrift = async (deps) => {
4536
5725
  return {
4537
5726
  name,
4538
5727
  status: "fail",
4539
- detail: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error.message}`,
5728
+ detail: `${tokenFile} is not valid JSON: ${error.message}`,
4540
5729
  remediation: "Run `lemony design-tokens validate` to find the problem."
4541
5730
  };
4542
5731
  }
4543
5732
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
4544
5733
  name,
4545
5734
  status: "fail",
4546
- detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
5735
+ detail: `${tokenFile} is not a JSON object.`,
4547
5736
  remediation: "Run `lemony design-tokens validate` to find the problem."
4548
5737
  };
4549
- const invalid = summarizeTokenFileViolations(raw);
5738
+ const invalid = summarizeTokenFileViolations(raw, tokenFile);
4550
5739
  if (invalid !== void 0) return {
4551
5740
  name,
4552
5741
  status: "fail",
4553
- detail: `${DESIGN_TOKENS_FILE} fails validation: ${invalid}`,
5742
+ detail: `${tokenFile} fails validation: ${invalid}`,
4554
5743
  remediation: "Run `lemony design-tokens validate` to find the problem."
4555
5744
  };
4556
5745
  const report = driftReport(parsed);
@@ -4579,7 +5768,7 @@ const checkDesignToolDrift = async (deps) => {
4579
5768
  };
4580
5769
  }
4581
5770
  };
4582
- const checkHookLib = async (deps) => {
5771
+ const checkHookLib = async (deps, restore) => {
4583
5772
  const name = "hook-lib";
4584
5773
  let helpers;
4585
5774
  try {
@@ -4629,7 +5818,8 @@ const checkHookLib = async (deps) => {
4629
5818
  if (!await isExecutable(installed)) return {
4630
5819
  helper,
4631
5820
  kind: "mode",
4632
- description: `${helper} is not executable`
5821
+ description: `${helper} is not executable`,
5822
+ execBitOnly: await isReadable(installed)
4633
5823
  };
4634
5824
  if (!await isReadable(installed)) return {
4635
5825
  helper,
@@ -4645,15 +5835,104 @@ const checkHookLib = async (deps) => {
4645
5835
  };
4646
5836
  const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
4647
5837
  const remediations = [];
4648
- if (problems.some((problem) => problem.kind === "missing")) remediations.push("Run `lemony repair` to restore the missing helpers");
4649
- 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`);
4650
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)`);
4651
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\``);
4652
5847
  return {
4653
5848
  name,
4654
5849
  status: "warn",
4655
- detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description).join("; ")} — agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime.`,
4656
- 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)}.`
4657
5936
  };
4658
5937
  };
4659
5938
  const isReadable = async (path) => {
@@ -4664,10 +5943,89 @@ const isReadable = async (path) => {
4664
5943
  return false;
4665
5944
  }
4666
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
+ };
4667
6018
  //#endregion
4668
6019
  //#region src/status/parse-task-branch.ts
4669
- const TASK_BRANCH = /^harness\/(\d+)-.+$/;
4670
- 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
+ };
4671
6029
  //#endregion
4672
6030
  //#region src/status/status.ts
4673
6031
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
@@ -4676,8 +6034,7 @@ const runStatus = async (deps) => {
4676
6034
  const config = await readHarnessConfig(repoRoot);
4677
6035
  const lastSession = await readPointer(repoRoot, deps.readGitUserEmail);
4678
6036
  const { branch, behind } = await deps.gitBehind(repoRoot);
4679
- const provider = createTaskTrackerProvider(config.task_storage.type, deps.runCommand);
4680
- 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);
4681
6038
  return {
4682
6039
  vendorVersion: config.vendor_version,
4683
6040
  taskStorageRepo: config.task_storage.repo,
@@ -4686,16 +6043,16 @@ const runStatus = async (deps) => {
4686
6043
  behind,
4687
6044
  lastSession,
4688
6045
  openDiscoveries,
4689
- designToolDrift: await readDriftState(repoRoot)
6046
+ designToolDrift: await readDriftState(repoRoot, resolveTokenFile(config.design_tokens.file))
4690
6047
  };
4691
6048
  };
4692
- const readDriftState = async (repoRoot) => {
6049
+ const readDriftState = async (repoRoot, tokenFile) => {
4693
6050
  try {
4694
- const raw = await readOptionalTokenFile(repoRoot);
6051
+ const raw = await readOptionalTokenFile(repoRoot, tokenFile);
4695
6052
  if (raw === void 0) return null;
4696
6053
  const parsed = JSON.parse(raw);
4697
6054
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
4698
- if (summarizeTokenFileViolations(raw) !== void 0) return null;
6055
+ if (summarizeTokenFileViolations(raw, tokenFile) !== void 0) return null;
4699
6056
  return driftReport(parsed).state;
4700
6057
  } catch {
4701
6058
  return null;
@@ -4770,8 +6127,8 @@ const COMMANDS = [
4770
6127
  },
4771
6128
  {
4772
6129
  name: "status",
4773
- summary: "Show installed version, branch drift, and open tasks.",
4774
- usage: "lemony status"
6130
+ summary: "Show installed version, branch drift, and open tasks (`--local`: no network call).",
6131
+ usage: "lemony status [--local]"
4775
6132
  },
4776
6133
  {
4777
6134
  name: "emit",
@@ -4783,6 +6140,11 @@ const COMMANDS = [
4783
6140
  summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
4784
6141
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
4785
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
+ },
4786
6148
  {
4787
6149
  name: "design-tokens",
4788
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).",
@@ -4801,107 +6163,43 @@ const COMMANDS = [
4801
6163
  {
4802
6164
  name: "telemetry",
4803
6165
  summary: "Inspect and control anonymous telemetry (on by default): show what is sent, force a send, opt out, opt back in.",
4804
- usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
4805
- }
4806
- ];
4807
- //#endregion
4808
- //#region src/help/help.ts
4809
- const pad = (text, width) => text + " ".repeat(Math.max(0, width - text.length));
4810
- const topLevelHelp = () => {
4811
- const width = Math.max(...COMMANDS.map((c) => c.name.length));
4812
- return [
4813
- "Lemony — the SDD harness for AI coding (vendor CLI).",
4814
- "",
4815
- "Usage: lemony <command> [flags]",
4816
- "",
4817
- "Commands:",
4818
- ...COMMANDS.map((c) => ` ${pad(c.name, width)} ${c.summary}`),
4819
- "",
4820
- "Run `lemony <command> --help` (or `-h`) for command-specific usage.",
4821
- "`lemony version` (or `--version` / `-v`) prints the installed CLI version."
4822
- ].join("\n");
4823
- };
4824
- const commandHelp = (name) => {
4825
- const command = COMMANDS.find((c) => c.name === name);
4826
- if (!command) return void 0;
4827
- return [
4828
- `Usage: ${command.usage}`,
4829
- "",
4830
- command.summary
4831
- ].join("\n");
4832
- };
4833
- const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
4834
- //#endregion
4835
- //#region src/render/render.ts
4836
- const PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/g;
4837
- const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
4838
- if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
4839
- return vars[key];
4840
- });
4841
- //#endregion
4842
- //#region src/baseline/baseline.ts
4843
- const STAGING_DIR$1 = ".staging";
4844
- const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
4845
- const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
4846
- const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
4847
- const writeBaseline = async (repoRoot, version, files) => {
4848
- const root = baselineRootDir(repoRoot);
4849
- const staging = join(root, STAGING_DIR$1);
4850
- await rm(staging, {
4851
- recursive: true,
4852
- force: true
4853
- });
4854
- await mkdir(staging, { recursive: true });
4855
- await Promise.all(files.map(async ({ relPath, content }) => {
4856
- const dest = join(staging, relPath);
4857
- await mkdir(dirname(dest), { recursive: true });
4858
- await writeFile(dest, content);
4859
- }));
4860
- const versionDir = baselineVersionDir(repoRoot, version);
4861
- await rm(versionDir, {
4862
- recursive: true,
4863
- force: true
4864
- });
4865
- await rename(staging, versionDir);
4866
- await pruneOtherVersions(root, version);
4867
- };
4868
- const pruneOtherVersions = async (root, keep) => {
4869
- const entries = await readdir(root, { withFileTypes: true });
4870
- await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
4871
- recursive: true,
4872
- force: true
4873
- })));
4874
- };
4875
- const readBaseline = async (repoRoot, version) => {
4876
- const versionDir = baselineVersionDir(repoRoot, version);
4877
- let relPaths;
4878
- try {
4879
- relPaths = await listFiles(versionDir);
4880
- } catch (error) {
4881
- if (isENOENT$1(error)) return /* @__PURE__ */ new Map();
4882
- throw error;
6166
+ usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
4883
6167
  }
4884
- const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
4885
- return new Map(entries);
6168
+ ];
6169
+ //#endregion
6170
+ //#region src/help/help.ts
6171
+ const pad = (text, width) => text + " ".repeat(Math.max(0, width - text.length));
6172
+ const topLevelHelp = () => {
6173
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
6174
+ return [
6175
+ "Lemony — the SDD harness for AI coding (vendor CLI).",
6176
+ "",
6177
+ "Usage: lemony <command> [flags]",
6178
+ "",
6179
+ "Commands:",
6180
+ ...COMMANDS.map((c) => ` ${pad(c.name, width)} ${c.summary}`),
6181
+ "",
6182
+ "Run `lemony <command> --help` (or `-h`) for command-specific usage.",
6183
+ "`lemony version` (or `--version` / `-v`) prints the installed CLI version."
6184
+ ].join("\n");
4886
6185
  };
4887
- const findBaselineVersion = async (repoRoot) => {
4888
- const root = baselineRootDir(repoRoot);
4889
- let entries;
4890
- try {
4891
- entries = await readdir(root, { withFileTypes: true });
4892
- } catch (error) {
4893
- if (isENOENT$1(error)) return null;
4894
- throw error;
4895
- }
4896
- const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
4897
- if (versions.length <= 1) return versions[0] ?? null;
4898
- const withMtime = await Promise.all(versions.map(async (version) => ({
4899
- version,
4900
- mtimeMs: (await stat(join(root, version))).mtimeMs
4901
- })));
4902
- withMtime.sort((a, b) => a.mtimeMs - b.mtimeMs || a.version.localeCompare(b.version, void 0, { numeric: true }));
4903
- return withMtime.at(-1)?.version ?? null;
6186
+ const commandHelp = (name) => {
6187
+ const command = COMMANDS.find((c) => c.name === name);
6188
+ if (!command) return void 0;
6189
+ return [
6190
+ `Usage: ${command.usage}`,
6191
+ "",
6192
+ command.summary
6193
+ ].join("\n");
4904
6194
  };
6195
+ const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
6196
+ //#endregion
6197
+ //#region src/render/render.ts
6198
+ const PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/g;
6199
+ const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
6200
+ if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
6201
+ return vars[key];
6202
+ });
4905
6203
  //#endregion
4906
6204
  //#region src/snapshot/snapshot.ts
4907
6205
  const STAGING_DIR = ".staging";
@@ -5020,152 +6318,6 @@ const cleanupSnapshots = async (repoRoot) => {
5020
6318
  return versions;
5021
6319
  };
5022
6320
  //#endregion
5023
- //#region src/merge/conflict-markers.constant.ts
5024
- const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
5025
- const CONFLICT_MARKER_SEPARATOR = "=======";
5026
- const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
5027
- //#endregion
5028
- //#region src/merge/conflict-markers.ts
5029
- const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
5030
- const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
5031
- //#endregion
5032
- //#region src/merge/three-way-merge.ts
5033
- const threeWayMerge = (base, client, vendor) => {
5034
- const baseLines = base.split("\n");
5035
- const clientLines = client.split("\n");
5036
- const vendorLines = vendor.split("\n");
5037
- const regions = diff3Regions(baseLines, clientLines, vendorLines);
5038
- const out = [];
5039
- let conflicted = false;
5040
- for (const region of regions) {
5041
- if (region.stable) {
5042
- out.push(...region.lines);
5043
- continue;
5044
- }
5045
- if (linesEqual(region.client, region.vendor)) out.push(...region.client);
5046
- else {
5047
- conflicted = true;
5048
- out.push(CONFLICT_MARKER_CLIENT, ...region.client, CONFLICT_MARKER_SEPARATOR, ...region.vendor, CONFLICT_MARKER_VENDOR);
5049
- }
5050
- }
5051
- return {
5052
- merged: out.join("\n"),
5053
- conflicted
5054
- };
5055
- };
5056
- const linesEqual = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
5057
- const diff3Regions = (base, client, vendor) => {
5058
- const hunks = [...diffRegions(base, client).map((d) => ({
5059
- ...d,
5060
- side: "client"
5061
- })), ...diffRegions(base, vendor).map((d) => ({
5062
- ...d,
5063
- side: "vendor"
5064
- }))].toSorted((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
5065
- const regions = [];
5066
- let baseCursor = 0;
5067
- let i = 0;
5068
- const emitStable = (until) => {
5069
- if (until > baseCursor) {
5070
- regions.push({
5071
- stable: true,
5072
- lines: base.slice(baseCursor, until)
5073
- });
5074
- baseCursor = until;
5075
- }
5076
- };
5077
- while (i < hunks.length) {
5078
- const first = hunks[i];
5079
- if (first === void 0) break;
5080
- let regionBaseStart = first.baseStart;
5081
- let regionBaseEnd = first.baseEnd;
5082
- const group = [first];
5083
- i += 1;
5084
- while (i < hunks.length) {
5085
- const next = hunks[i];
5086
- if (next === void 0 || next.baseStart > regionBaseEnd) break;
5087
- regionBaseEnd = Math.max(regionBaseEnd, next.baseEnd);
5088
- group.push(next);
5089
- i += 1;
5090
- }
5091
- emitStable(regionBaseStart);
5092
- const touchedByClient = group.some((h) => h.side === "client");
5093
- const touchedByVendor = group.some((h) => h.side === "vendor");
5094
- if (touchedByClient && touchedByVendor) regions.push({
5095
- stable: false,
5096
- client: projectSide(group, "client", client, regionBaseStart, regionBaseEnd),
5097
- vendor: projectSide(group, "vendor", vendor, regionBaseStart, regionBaseEnd)
5098
- });
5099
- else {
5100
- const side = touchedByClient ? "client" : "vendor";
5101
- regions.push({
5102
- stable: true,
5103
- lines: projectSide(group, side, side === "client" ? client : vendor, regionBaseStart, regionBaseEnd)
5104
- });
5105
- }
5106
- baseCursor = regionBaseEnd;
5107
- }
5108
- emitStable(base.length);
5109
- return regions;
5110
- };
5111
- const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
5112
- const own = group.filter((h) => h.side === side);
5113
- let sideStart = Number.POSITIVE_INFINITY;
5114
- let sideEnd = Number.NEGATIVE_INFINITY;
5115
- let baseStart = Number.POSITIVE_INFINITY;
5116
- let baseEnd = Number.NEGATIVE_INFINITY;
5117
- for (const h of own) {
5118
- sideStart = Math.min(sideStart, h.sideStart);
5119
- sideEnd = Math.max(sideEnd, h.sideEnd);
5120
- baseStart = Math.min(baseStart, h.baseStart);
5121
- baseEnd = Math.max(baseEnd, h.baseEnd);
5122
- }
5123
- const start = sideStart - (baseStart - regionBaseStart);
5124
- const end = sideEnd + (regionBaseEnd - baseEnd);
5125
- return sideLines.slice(start, end);
5126
- };
5127
- const diffRegions = (base, other) => {
5128
- const matches = lcsMatches(base, other);
5129
- const diffs = [];
5130
- let baseTail = 0;
5131
- let otherTail = 0;
5132
- for (const [baseIdx, otherIdx] of matches) {
5133
- if (baseIdx > baseTail || otherIdx > otherTail) diffs.push({
5134
- baseStart: baseTail,
5135
- baseEnd: baseIdx,
5136
- sideStart: otherTail,
5137
- sideEnd: otherIdx
5138
- });
5139
- baseTail = baseIdx + 1;
5140
- otherTail = otherIdx + 1;
5141
- }
5142
- if (base.length > baseTail || other.length > otherTail) diffs.push({
5143
- baseStart: baseTail,
5144
- baseEnd: base.length,
5145
- sideStart: otherTail,
5146
- sideEnd: other.length
5147
- });
5148
- return diffs;
5149
- };
5150
- const lcsMatches = (base, other) => {
5151
- const n = base.length;
5152
- const m = other.length;
5153
- const width = m + 1;
5154
- const dp = new Int32Array((n + 1) * width);
5155
- const at = (i, j) => dp[i * width + j] ?? 0;
5156
- 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));
5157
- const matches = [];
5158
- let i = 0;
5159
- let j = 0;
5160
- while (i < n && j < m) if (base[i] === other[j]) {
5161
- matches.push([i, j]);
5162
- i += 1;
5163
- j += 1;
5164
- } else if (at(i + 1, j) >= at(i, j + 1)) i += 1;
5165
- else j += 1;
5166
- return matches;
5167
- };
5168
- //#endregion
5169
6321
  //#region src/update/engine.ts
5170
6322
  const cohesionKey = (relPath) => {
5171
6323
  const prefix = SKILLS_DIR + sep;
@@ -5837,7 +6989,7 @@ const runRepair = async (options) => {
5837
6989
  const DOCS_PREFIX = `docs${sep}`;
5838
6990
  const runUninstall = async (options) => {
5839
6991
  const { repoRoot } = options;
5840
- 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).`);
5841
6993
  const baselineVersion = await findBaselineVersion(repoRoot);
5842
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.");
5843
6995
  const config = await readHarnessConfig(repoRoot);
@@ -6070,6 +7222,24 @@ const parseFlag = (args, name) => {
6070
7222
  const rejectValuelessFlags = (verb, args, names) => {
6071
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.`);
6072
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
+ }
7242
+ };
6073
7243
  const readHarnessVersion = async () => {
6074
7244
  const raw = await readFile(PACKAGE_JSON_PATH, "utf8");
6075
7245
  const parsed = JSON.parse(raw);
@@ -6141,45 +7311,6 @@ const buildResolveDeps = () => {
6141
7311
  close: () => rl.close()
6142
7312
  };
6143
7313
  };
6144
- const gitBehind = async (repoRoot) => {
6145
- const run = makeRunCommand();
6146
- const branchResult = await run("git", [
6147
- "-C",
6148
- repoRoot,
6149
- "symbolic-ref",
6150
- "--quiet",
6151
- "HEAD"
6152
- ]);
6153
- const ref = branchResult.code === 0 ? branchResult.stdout.trim() : "";
6154
- const branch = ref.startsWith("refs/heads/") ? ref.slice(11) || null : null;
6155
- const headRef = await run("git", [
6156
- "-C",
6157
- repoRoot,
6158
- "symbolic-ref",
6159
- "--quiet",
6160
- "refs/remotes/origin/HEAD"
6161
- ]);
6162
- if (headRef.code !== 0) return {
6163
- branch,
6164
- behind: null
6165
- };
6166
- const count = await run("git", [
6167
- "-C",
6168
- repoRoot,
6169
- "rev-list",
6170
- "--count",
6171
- `HEAD..${headRef.stdout.trim().replace(/^refs\/remotes\//, "")}`
6172
- ]);
6173
- if (count.code !== 0) return {
6174
- branch,
6175
- behind: null
6176
- };
6177
- const parsed = Number.parseInt(count.stdout.trim(), 10);
6178
- return {
6179
- branch,
6180
- behind: Number.isNaN(parsed) ? null : parsed
6181
- };
6182
- };
6183
7314
  const install = async (args) => {
6184
7315
  const repoRoot = cwd();
6185
7316
  if (await pathExists(join(repoRoot, "harness.config.yml"))) {
@@ -6504,6 +7635,42 @@ const discovery = async (args) => {
6504
7635
  if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
6505
7636
  exit(1);
6506
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);
6507
7674
  const designTokens = async (args) => {
6508
7675
  const action = args[0] ?? "validate";
6509
7676
  if (action === "validate") return designTokensValidate(args);
@@ -6514,13 +7681,15 @@ const designTokens = async (args) => {
6514
7681
  };
6515
7682
  const designTokensValidate = async (args) => {
6516
7683
  const config = await readOptionalHarnessConfig(cwd());
7684
+ const tokenFile = tokenFileOf(config);
6517
7685
  const result = await runValidate({
6518
7686
  repoRoot: cwd(),
7687
+ tokenFile,
6519
7688
  scanDir: parseFlag(args, "scan"),
6520
7689
  extraExtensions: config?.design_tokens.scan_extensions
6521
7690
  });
6522
7691
  if (!result.tokensFound) {
6523
- console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
7692
+ console.log(`No ${tokenFile} — design-tokens validate skipped (consume-if-exists; the gates never create it).`);
6524
7693
  return;
6525
7694
  }
6526
7695
  if (result.ok) {
@@ -6549,9 +7718,12 @@ const contrastFailedLine = (pairCheck, verify) => {
6549
7718
  return `design-tokens contrast failed: ${pairCheck}; design_tokens.verify ${quotedCommand(verify.command)} ${outcome}.`;
6550
7719
  };
6551
7720
  const designTokensContrast = async () => {
6552
- const verify = (await readOptionalHarnessConfig(cwd()))?.design_tokens.verify;
7721
+ const config = await readOptionalHarnessConfig(cwd());
7722
+ const verify = config?.design_tokens.verify;
7723
+ const tokenFile = tokenFileOf(config);
6553
7724
  const result = await runContrast({
6554
7725
  repoRoot: cwd(),
7726
+ tokenFile,
6555
7727
  ...verify === void 0 ? {} : { verify: {
6556
7728
  command: verify,
6557
7729
  run: makeVerifierRunCommand()
@@ -6566,14 +7738,14 @@ const designTokensContrast = async () => {
6566
7738
  };
6567
7739
  const failedVerifier = result.verify !== void 0 && result.verify.code !== 0 ? result.verify : void 0;
6568
7740
  if (!result.tokensFound) {
6569
- console.log(result.verify === void 0 ? `No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the gates never create it).` : `No docs/design-tokens.json — design-tokens contrast pair check skipped (consume-if-exists; the gates never create it); running the declared design_tokens.verify.`);
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.`);
6570
7742
  if (result.verify === void 0) return;
6571
7743
  if (failedVerifier) console.error(`design-tokens contrast found 1 issue(s):`);
6572
7744
  forwardVerifier();
6573
7745
  await Promise.all([drained(stdout), drained(stderr)]);
6574
7746
  if (failedVerifier) {
6575
7747
  console.error(` design_tokens.verify ${quotedCommand(failedVerifier.command)} ${verifierEnding(failedVerifier)}.`);
6576
- console.error(contrastFailedLine("pair check skipped (no docs/design-tokens.json)", failedVerifier));
7748
+ console.error(contrastFailedLine(`pair check skipped (no ${tokenFile})`, failedVerifier));
6577
7749
  process.exitCode = 1;
6578
7750
  return;
6579
7751
  }
@@ -6605,8 +7777,10 @@ const designTokensImport = async (args) => {
6605
7777
  rejectValuelessFlags("design-tokens import", args, ["only"]);
6606
7778
  const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
6607
7779
  if (only?.length === 0) throw new Error("design-tokens import: --only names no path.");
7780
+ const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
6608
7781
  const result = await runImport({
6609
7782
  repoRoot: cwd(),
7783
+ tokenFile,
6610
7784
  neutralPath: resolve(cwd(), from),
6611
7785
  apply: args.includes("--apply"),
6612
7786
  only
@@ -6619,7 +7793,7 @@ const designTokensImport = async (args) => {
6619
7793
  return;
6620
7794
  }
6621
7795
  if (result.applied) {
6622
- 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}.`);
6623
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(", ")}`);
6624
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(", ")}`);
6625
7799
  return;
@@ -6655,14 +7829,16 @@ const designTokensExport = async (args) => {
6655
7829
  console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
6656
7830
  exit(1);
6657
7831
  }
7832
+ const tokenFile = tokenFileOf(await readOptionalHarnessConfig(cwd()));
6658
7833
  const result = await runExport({
6659
7834
  repoRoot: cwd(),
7835
+ tokenFile,
6660
7836
  ...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
6661
7837
  ...out ? { outPath: resolve(cwd(), out) } : {},
6662
7838
  record: args.includes("--record")
6663
7839
  });
6664
7840
  if (!result.tokensFound) {
6665
- console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; export never creates it).`);
7841
+ console.log(`No ${tokenFile} — design-tokens export skipped (consume-if-exists; export never creates it).`);
6666
7842
  return;
6667
7843
  }
6668
7844
  if (result.problems.length > 0) {
@@ -6751,12 +7927,14 @@ const spinoff = async (args) => {
6751
7927
  console.error(`spinoff failed at "${result.failedStep}": ${result.reason}`);
6752
7928
  exit(1);
6753
7929
  };
6754
- const status = async () => {
7930
+ const status = async (args) => {
7931
+ const local = args.includes("--local");
6755
7932
  const report = await runStatus({
6756
7933
  repoRoot: cwd(),
6757
7934
  readGitUserEmail,
6758
- gitBehind,
6759
- runCommand: makeRunCommand()
7935
+ gitBehind: (root) => readGitBehind(root, makeRunCommand()),
7936
+ runCommand: makeRunCommand(),
7937
+ local
6760
7938
  });
6761
7939
  const behindText = report.behind === null ? "behind unknown" : report.behind === 0 ? "up to date" : `${report.behind} behind`;
6762
7940
  const discoveries = report.openDiscoveries === null ? "unknown" : String(report.openDiscoveries);
@@ -6766,7 +7944,7 @@ const status = async () => {
6766
7944
  console.log(` Active task: ${report.activeTask ?? "(none)"}`);
6767
7945
  console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
6768
7946
  console.log(` Last session: ${report.lastSession ?? "(none)"}`);
6769
- console.log(` Open discoveries: ${discoveries}`);
7947
+ if (!local) console.log(` Open discoveries: ${discoveries}`);
6770
7948
  if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
6771
7949
  };
6772
7950
  const DESIGN_TOOL_STATUS = {
@@ -6853,6 +8031,9 @@ const main = async () => {
6853
8031
  case "discovery":
6854
8032
  await discovery(args);
6855
8033
  return;
8034
+ case "checkpoint":
8035
+ await checkpoint(args);
8036
+ return;
6856
8037
  case "design-tokens":
6857
8038
  await designTokens(args);
6858
8039
  return;
@@ -6866,7 +8047,7 @@ const main = async () => {
6866
8047
  await runTelemetry(args);
6867
8048
  return;
6868
8049
  case "status":
6869
- await status();
8050
+ await status(args);
6870
8051
  return;
6871
8052
  case "doctor":
6872
8053
  await doctor();