@lemoncode/lemony 0.1.0 → 0.1.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/NOTICE +39 -0
  2. package/catalog/VERSION +1 -1
  3. package/catalog/agents/architect.md +4 -4
  4. package/catalog/agents/fit-assessment.md +1 -1
  5. package/catalog/agents/implementer.md +15 -8
  6. package/catalog/agents/orchestrator.md +165 -24
  7. package/catalog/agents/reviewer.md +7 -7
  8. package/catalog/agents/spec-author.md +4 -4
  9. package/catalog/agents/ui-designer.md +115 -15
  10. package/catalog/commands/add-capability.md +3 -3
  11. package/catalog/commands/resume.md +10 -4
  12. package/catalog/commands/spinoff.md +2 -2
  13. package/catalog/commands/sync-design-tokens.md +29 -0
  14. package/catalog/harness.config.schema.json +14 -0
  15. package/catalog/hooks/init.sh +11 -11
  16. package/catalog/hooks/lib/lemony.sh +3 -3
  17. package/catalog/hooks/lib/playbook-scan.sh +10 -11
  18. package/catalog/hooks/session-close.sh +7 -7
  19. package/catalog/schemas/tier2-events-history.md +11 -11
  20. package/catalog/schemas/tier2-events.md +46 -47
  21. package/catalog/skills/a11y-audit/SKILL.md +121 -0
  22. package/catalog/skills/bootstrap-architecture/SKILL.md +3 -3
  23. package/catalog/skills/build-ui/SKILL.md +147 -0
  24. package/catalog/skills/build-ui/accessibility.md +101 -0
  25. package/catalog/skills/build-ui/anti-slop.md +107 -0
  26. package/catalog/skills/code-explorer/SKILL.md +1 -1
  27. package/catalog/skills/design-critique/SKILL.md +110 -0
  28. package/catalog/skills/design-tool-sync/SKILL.md +120 -0
  29. package/catalog/skills/grill-ui/SKILL.md +187 -0
  30. package/catalog/skills/grill-ui/ui-handoff-format.md +148 -0
  31. package/catalog/skills/grill-with-docs/SKILL.md +9 -2
  32. package/catalog/skills/mutation-testing/SKILL.md +1 -1
  33. package/catalog/skills/note-side-finding/SKILL.md +1 -1
  34. package/catalog/skills/playbook-iterate/SKILL.md +2 -2
  35. package/catalog/skills/review-pr/SKILL.md +3 -3
  36. package/catalog/skills/task-closeout/SKILL.md +9 -8
  37. package/catalog/skills/update-architecture/SKILL.md +3 -3
  38. package/catalog/templates/claude-code/agents.md.tpl +16 -10
  39. package/catalog/templates/claude-code/docs/playbooks/README.md.tpl +1 -3
  40. package/catalog/templates/claude-code/harness.config.yml.tpl +9 -1
  41. package/dist/cli.mjs +1286 -1665
  42. package/package.json +13 -4
  43. package/catalog/agents/README.md +0 -29
  44. package/catalog/hooks/README.md +0 -56
  45. package/catalog/playbook-format.md +0 -198
  46. package/catalog/schemas/README.md +0 -13
  47. package/catalog/skills/README.md +0 -62
  48. package/catalog/templates/README.md +0 -32
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } 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 { delimiter, dirname, join, sep } from "node:path";
4
+ import { delimiter, dirname, extname, join, 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";
@@ -10,66 +10,19 @@ import { z } from "zod";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
11
  import { constants } from "node:fs";
12
12
  //#region src/events/append.ts
13
- /**
14
- * Append one JSON line to `eventsPath`, creating parent dirs and the file as
15
- * needed.
16
- *
17
- * **Concurrency.** A single `write(2)` to an `O_APPEND`-opened file is atomic
18
- * up to `PIPE_BUF` (4096 bytes on macOS/Linux) — every event in the seven-type
19
- * catalog stays well under that. `fs.appendFile` opens with `O_APPEND` and
20
- * issues one write, so two concurrent emitters (a hook + the Orchestrator's
21
- * `task_done` emit, say) can interleave whole lines but never tear a single
22
- * one and never lose an event. This matches the `O_APPEND` option called out
23
- * as safe in `catalog/schemas/tier2-events.md`.
24
- */
25
13
  const appendEvent = async (eventsPath, event) => {
26
14
  await mkdir(dirname(eventsPath), { recursive: true });
27
15
  await appendFile(eventsPath, `${JSON.stringify(event)}\n`);
28
16
  };
29
17
  //#endregion
30
18
  //#region src/config/config.constant.ts
31
- /**
32
- * Constants and enumerations for `harness.config.yml` (decision #53). The Zod
33
- * schema (`config.schema.ts`) is built from these; keeping the literal sets here
34
- * lets non-schema code (the installer, the bash defaults guard) reference the
35
- * same source of truth without importing Zod.
36
- */
37
19
  const HARNESS_CONFIG_FILENAME = "harness.config.yml";
38
- /**
39
- * Keys retired from the schema but **tolerated on read** so an existing install's
40
- * config doesn't fail-closed when the CLI is upgraded past the removal (ADR 0015).
41
- * The reader strips these before `.strict()` validation (so an honest typo still
42
- * fails loud), and the writer deletes them on the next config write (so the dead
43
- * line disappears the first time any verb rewrites the file). `profile` was the
44
- * coarse skill tier (#39/#50), removed when profiles collapsed to a single
45
- * capability-gated skill set.
46
- */
47
20
  const DEPRECATED_CONFIG_KEYS = ["profile"];
48
- /** Committed JSON Schema the IDE reads via the `$schema` header (decision #53e/B1.5). */
49
21
  const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
50
- /**
51
- * Sentinel value the installer writes to `task_storage.repo` when it cannot
52
- * resolve a real slug (no git origin remote, no `--task-storage-repo` flag,
53
- * non-TTY install) so the file is well-formed. Every downstream consumer treats
54
- * this literal as "unset" — `emit` refuses to stamp it onto telemetry events.
55
- */
56
22
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
57
- /** AI-coding harnesses this build can target (#53j). Only `claude-code` is operative. */
58
23
  const TARGETS = ["claude-code"];
59
- /** Task-storage backends (#53a). Only `github` in Sprint 0; adapters are a Fase 1+ pattern. */
60
24
  const TASK_STORAGE_TYPES = ["github"];
61
- /**
62
- * `task_storage.repo` shape (#53a): `owner/name` — two non-empty, slash-free
63
- * segments. Permissive on the characters GitHub allows (letters, digits, `-`,
64
- * `_`, `.`); strict on the shape so a hand-edited slug can't be a bare flag (e.g.
65
- * `--foo`) that a downstream `gh --repo <slug>` would misread. The `OWNER/REPO`
66
- * placeholder matches and is rejected separately by the emit/sync guards.
67
- */
68
25
  const TASK_STORAGE_REPO_PATTERN = /^[^\s/]+\/[^\s/]+$/;
69
- /**
70
- * Agent slugs valid in the `agents` array (#53h). `orchestrator` is the hat and
71
- * is required; `ui-designer` is a catalog slot, inactive by default (decision #11).
72
- */
73
26
  const AGENTS = [
74
27
  "orchestrator",
75
28
  "spec-author",
@@ -78,21 +31,9 @@ const AGENTS = [
78
31
  "architect",
79
32
  "ui-designer"
80
33
  ];
81
- /** The hat — must always be present in the `agents` array (#53h). */
82
34
  const REQUIRED_AGENT = "orchestrator";
83
- /**
84
- * `vendor_version` format (#53i): an exact semver with an optional prerelease
85
- * tag (`alpha`/`beta`/`rc`). Registry-existence is checked in P7 (B1.3), not here.
86
- */
87
35
  const VENDOR_VERSION_REGEX = /^\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$/;
88
- /** A copy-pasteable `vendor_version` for the did-you-mean error message. */
89
36
  const VENDOR_VERSION_EXAMPLE = "0.1.0-alpha.0";
90
- /**
91
- * Canonical defaults for the optional `paths.*` keys (#53g, B1.1-fu). Declared
92
- * once here in TS; the bash `playbook-scan.sh` keeps its own literals for the
93
- * two keys it reads, and a guard test (`paths-defaults.spec.ts`) asserts the two
94
- * stay equal so the duplication can never silently drift.
95
- */
96
37
  const PATHS_DEFAULTS = {
97
38
  state: ".claude/state",
98
39
  skills: ".claude/skills",
@@ -102,13 +43,6 @@ const PATHS_DEFAULTS = {
102
43
  };
103
44
  //#endregion
104
45
  //#region src/config/config-error.ts
105
- /**
106
- * Turn a `ZodError` from validating `harness.config.yml` into a human-readable,
107
- * actionable message (decision B1.2). A developer edits this YAML by hand, so an
108
- * unknown key gets a did-you-mean suggestion (the nearest valid sibling key) and
109
- * a malformed `vendor_version` gets the expected format plus a concrete example,
110
- * instead of the raw Zod dump.
111
- */
112
46
  const formatConfigError = (schema, error) => {
113
47
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
114
48
  };
@@ -128,16 +62,8 @@ const formatIssue = (schema, issue) => {
128
62
  if (issue.code === "invalid_value") return [`${where} must be one of: ${issue.values.map((v) => JSON.stringify(v)).join(", ")}.`];
129
63
  return [`${where}: ${issue.message}`];
130
64
  };
131
- /** A `vendor_version` issue (top-level scalar) gets the example appended. */
132
65
  const isVendorVersion = (path) => path.length === 1 && path[0] === "vendor_version";
133
- /** Human label for an issue path: the dotted key, or `(root)` for the top object. */
134
66
  const pathLabel = (path) => path.length === 0 ? "(root)" : path.map(String).join(".");
135
- /**
136
- * The valid keys of the object at `path`, read from the schema itself so the
137
- * suggestion set never drifts from the schema. Walks the shape, unwrapping the
138
- * `default()`/`optional()` wrappers that sit between an object and its `shape`.
139
- * Returns null when the path doesn't resolve to an object (no suggestions then).
140
- */
141
67
  const validKeysAt = (schema, path) => {
142
68
  let shape = shapeOf(schema);
143
69
  for (const segment of path) {
@@ -152,12 +78,6 @@ const shapeOf = (schema) => {
152
78
  while (current?.def?.innerType) current = current.def.innerType;
153
79
  return current?.shape;
154
80
  };
155
- /**
156
- * The candidate closest to `key` by Levenshtein distance, or null when even the
157
- * closest is too far to be a plausible typo (distance over half the candidate's
158
- * length). Keeps "extra" from suggesting an unrelated key while still catching
159
- * "targett" → "target".
160
- */
161
81
  const nearestKey = (key, candidates) => {
162
82
  let best = null;
163
83
  let bestDistance = Infinity;
@@ -185,19 +105,6 @@ const levenshtein = (a, b) => {
185
105
  };
186
106
  //#endregion
187
107
  //#region src/config/config.schema.ts
188
- /**
189
- * The `harness.config.yml` schema (decision #53, B1). `.strict()` is recursive —
190
- * top level, `task_storage`, and `paths` all reject unknown keys so a typo'd key
191
- * fails loudly instead of silently disabling the override it was meant to set
192
- * (e.g. the awk hooks reading `paths.playbooks`). Optional `paths.*` keys carry
193
- * their canonical defaults, so `readHarnessConfig` returns the fully resolved
194
- * config (defaults applied) and `HarnessConfig` = `z.infer` of this schema is the
195
- * single source of truth for the config's shape.
196
- *
197
- * Models the keys the template ships (#53 a/g–k) plus `rollback` (b), which lands
198
- * in P7/S2 with the feature that reads it. `session_close` (c) blocks land with the
199
- * session hook, and `metrics` (d) stays a template comment until Fase 1+.
200
- */
201
108
  const pathsSchema = z.object({
202
109
  state: z.string().default(PATHS_DEFAULTS.state),
203
110
  skills: z.string().default(PATHS_DEFAULTS.skills),
@@ -209,24 +116,9 @@ const taskStorageSchema = z.object({
209
116
  type: z.enum(TASK_STORAGE_TYPES),
210
117
  repo: z.string().regex(TASK_STORAGE_REPO_PATTERN)
211
118
  }).strict();
212
- /**
213
- * The `rollback` block (#53b/#48). Only `keep_snapshots`: how many pre-update
214
- * snapshot bundles `rollback` rotation retains — a positive integer or the
215
- * `"unlimited"` sentinel (never rotate). Default `3` (#53b). The block is optional
216
- * and `.prefault({})` so an old config (pre-P7) without it still validates and
217
- * resolves to the default — the config writer never injects new keys (D4).
218
- */
219
119
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
220
- /**
221
- * The `telemetry` block (#229). v1 exposes only `enabled` — the committed,
222
- * team-wide off-switch over the on-by-default floor. `anonymous` is the only tier
223
- * (hardcoded in the send path), so there is **no `tier` key**: `tier` + the
224
- * `project` tier land together in a later version, and a committed `tier:` today is
225
- * a loud `.strict()` error, not an inert no-op. `enabled` defaults `true` and the
226
- * block is `.prefault({})` so an old config without it resolves to the floor
227
- * (consent precedence + fail-safe live in `src/telemetry/consent.ts`).
228
- */
229
120
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
121
+ const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
230
122
  const harnessConfigSchema = z.object({
231
123
  vendor_version: z.string().regex(VENDOR_VERSION_REGEX),
232
124
  target: z.enum(TARGETS),
@@ -234,20 +126,11 @@ const harnessConfigSchema = z.object({
234
126
  agents: z.array(z.enum(AGENTS)).refine((agents) => agents.includes(REQUIRED_AGENT), { error: `agents must include "${REQUIRED_AGENT}" (the orchestrator is the hat).` }),
235
127
  paths: pathsSchema,
236
128
  rollback: rollbackSchema,
237
- telemetry: telemetrySchema
129
+ telemetry: telemetrySchema,
130
+ design_tokens: designTokensSchema
238
131
  }).strict();
239
132
  //#endregion
240
133
  //#region src/config/config.ts
241
- /**
242
- * Read and validate `harness.config.yml` at the repo root. Re-parsed on every
243
- * read (no caching) so a hand edit applies immediately — the file is small and a
244
- * stale cache between CLI calls would surprise a developer editing it by hand.
245
- *
246
- * Validation is the Zod schema (the single source of truth, decision B1): the
247
- * returned config is the **fully resolved** object with `paths.*` defaults
248
- * applied. A schema failure is rendered through `formatConfigError` so the CLI
249
- * surfaces a did-you-mean / expected-format message instead of a raw Zod dump.
250
- */
251
134
  const readHarnessConfig = async (repoRoot) => {
252
135
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
253
136
  let raw;
@@ -272,17 +155,11 @@ const readHarnessConfig = async (repoRoot) => {
272
155
  };
273
156
  //#endregion
274
157
  //#region src/config/compare-version.ts
275
- /** Prerelease tags in ascending semver precedence (alpha < beta < rc < release). */
276
158
  const PRERELEASE_RANK = {
277
159
  alpha: 0,
278
160
  beta: 1,
279
161
  rc: 2
280
162
  };
281
- /**
282
- * Capturing form of `VENDOR_VERSION_REGEX` (config.constant): the same bounded
283
- * format `MAJOR.MINOR.PATCH[-(alpha|beta|rc).N]`, but with groups so we can read
284
- * the parts out. A non-match is the validity gate (parse → null).
285
- */
286
163
  const VERSION_PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/;
287
164
  const parse$1 = (raw) => {
288
165
  const m = VERSION_PARTS.exec(raw);
@@ -305,17 +182,6 @@ const parse$1 = (raw) => {
305
182
  }
306
183
  };
307
184
  };
308
- /**
309
- * Compare two `vendor_version` strings by semver precedence (#172): returns -1/0/1
310
- * for a<b / a==b / a>b, or `null` when either side is not a valid `vendor_version`
311
- * (callers degrade gracefully — neutral message, no guard — rather than guess a
312
- * direction from an unparseable string).
313
- *
314
- * The format is the bounded subset `MAJOR.MINOR.PATCH[-(alpha|beta|rc).N]`, so a full
315
- * semver dependency is overkill: core numbers compare numerically, a release (no
316
- * prerelease) outranks any prerelease of the same core, and among prereleases the tag
317
- * (alpha<beta<rc) then the numeric `.N` decide.
318
- */
319
185
  const compareVendorVersion = (a, b) => {
320
186
  const pa = parse$1(a);
321
187
  const pb = parse$1(b);
@@ -334,57 +200,18 @@ const compareVendorVersion = (a, b) => {
334
200
  };
335
201
  //#endregion
336
202
  //#region src/config/write-config.ts
337
- /**
338
- * Apply value bumps to a `harness.config.yml` source string via the `yaml`
339
- * Document API (decision D4): `parseDocument` keeps the document's comments and
340
- * formatting as a CST, `doc.set` rewrites only the touched scalar, and `toString`
341
- * re-emits everything else byte-for-byte. This is why `update` can edit the config
342
- * without yq and without flattening the template's rich comments.
343
- *
344
- * **Value-bumps only** — a key absent from the document is skipped, never injected.
345
- * New optional keys are covered by the Zod schema's `.default()`/`.prefault()`, so
346
- * an old config without them still validates; the writer never adds structure.
347
- *
348
- * On every write it also deletes any `DEPRECATED_CONFIG_KEYS` present (ADR 0015) —
349
- * so a config still carrying a retired key (e.g. `profile`) is cleaned up the first
350
- * time any verb rewrites it, pairing with the reader's read-time tolerance.
351
- */
352
203
  const setConfigValues = (rawYaml, updates) => {
353
204
  const doc = parseDocument(rawYaml);
354
205
  for (const [key, value] of Object.entries(updates)) if (doc.has(key)) doc.set(key, value);
355
206
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
356
207
  return doc.toString();
357
208
  };
358
- /**
359
- * Read `harness.config.yml` at the repo root, apply the value bumps, and write it
360
- * back. The on-disk counterpart of `setConfigValues` — `update` calls it to bump
361
- * `vendor_version` after a merge (and, en passant, to drop any retired key).
362
- */
363
209
  const writeConfigValues = async (repoRoot, updates) => {
364
210
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
365
211
  await writeFile(configPath, setConfigValues(await readFile(configPath, "utf8"), updates));
366
212
  };
367
213
  //#endregion
368
214
  //#region src/config/pointer.schema.ts
369
- /**
370
- * Canonical contract for the per-dev pointer frontmatter (`current-<user>.md`)
371
- * — decision B4.1. Single source of truth for the keys the lifecycle hooks
372
- * (`init.sh`, `session-close.sh`) WRITE and the `status` command READS. Lives
373
- * beside the other bash↔TS contract guard (`paths-defaults.spec.ts`).
374
- *
375
- * The fail-closed boot path stays bash-only (no node dependency, #54/#41), so
376
- * this schema can't be imported there. Instead `pointer.schema.spec.ts` reads
377
- * the hook source and asserts the bash writers/readers stay in lock-step with
378
- * these keys — the convergence guard that lets the read logic live in one place
379
- * without coupling the boot path to the CLI.
380
- *
381
- * Every scalar is lenient (absent | null | string | number | boolean): the
382
- * hooks emit YAML `null`, empty strings, and quoted/unquoted scalars
383
- * interchangeably, and a partial or hand-edited pointer must still parse — a
384
- * single stray scalar (e.g. a numeric `active_task`) degrades that one field,
385
- * it must not blank the whole pointer. `normalize` (status.ts) collapses the
386
- * "unset" variants to `null` and stringifies the rest.
387
- */
388
215
  const pointerScalar = z.union([
389
216
  z.string(),
390
217
  z.number(),
@@ -400,14 +227,6 @@ const pointerFrontmatterSchema = z.object({
400
227
  Object.keys(pointerFrontmatterSchema.shape);
401
228
  //#endregion
402
229
  //#region src/events/envelope.ts
403
- /**
404
- * Build the on-wire envelope every event line carries (see
405
- * `catalog/schemas/tier2-events.md`). Stamps `ts` in UTC ISO 8601 with the literal
406
- * `Z` suffix — `Date#toISOString` is already that format. Skips `task_id` when the
407
- * caller didn't provide one so the line stays sparse and the Zod schema for
408
- * task-less events (`session_closed`, `l3_bypass`) accepts it without a
409
- * placeholder.
410
- */
411
230
  const buildEnvelope = (input) => {
412
231
  const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
413
232
  const envelope = {
@@ -422,15 +241,6 @@ const buildEnvelope = (input) => {
422
241
  };
423
242
  //#endregion
424
243
  //#region src/events/events.model.ts
425
- /**
426
- * Tier 2 event types declared in `catalog/schemas/tier2-events.md`.
427
- *
428
- * Five emit in P5; `bug_post_merge` (P8) and `l3_bypass` (P6) are reserved so the
429
- * file is forward-compatible from `0.1.0-alpha.0` — readers dispatch on `type` and
430
- * skip unknown values. `followup_captured` (#112, `/spinoff`) is the eighth — emitted
431
- * when a non-blocking defect is parked mid-task as a stub. `step_completed` (#176)
432
- * is the ninth — emitted at each resolved human checkpoint in step-by-step mode.
433
- */
434
244
  const EVENT_TYPES = [
435
245
  "session_closed",
436
246
  "spec_created",
@@ -442,7 +252,6 @@ const EVENT_TYPES = [
442
252
  "followup_captured",
443
253
  "step_completed"
444
254
  ];
445
- /** Reason codes accepted by `session_closed` — Claude Code SessionEnd values plus `manual` for `/pause`. */
446
255
  const SESSION_CLOSE_REASONS = [
447
256
  "clear",
448
257
  "resume",
@@ -452,38 +261,23 @@ const SESSION_CLOSE_REASONS = [
452
261
  "other",
453
262
  "manual"
454
263
  ];
455
- /** Task-fit dial value, recorded on `task_done` (decisions #57–#61). */
456
264
  const TASK_LEVELS = [
457
265
  "L1",
458
266
  "L2",
459
267
  "L3"
460
268
  ];
461
- /** Bug severity, recorded on `bug_post_merge` (P8 meta-test). */
462
269
  const BUG_SEVERITIES = [
463
270
  "low",
464
271
  "medium",
465
272
  "high",
466
273
  "critical"
467
274
  ];
468
- /**
469
- * Implementation mode chosen at the L1 approval gate (#176), recorded on
470
- * `task_done`. Absent on L2 — the mode question only exists where `tasks.md` does.
471
- */
472
275
  const IMPLEMENTATION_MODES = ["all_at_once", "step_by_step"];
473
- /** Human checkpoint outcome, recorded on `step_completed` (#176). */
474
276
  const CHECKPOINT_RESULTS = [
475
277
  "ok",
476
278
  "changes",
477
279
  "ok_downgrade"
478
280
  ];
479
- /**
480
- * What kind of component a friction event is attributed to (#217). Optional on
481
- * `review_rejected` and `step_completed` — `attributed_name` (free string) names
482
- * the specific component. Free string by design this phase (measure-then-decide):
483
- * the emitting prompts list the roster and omit when unsure; the aggregation
484
- * script reports coverage and flags unknown names rather than the CLI enforcing a
485
- * registry.
486
- */
487
281
  const ATTRIBUTION_KINDS = [
488
282
  "agent",
489
283
  "skill",
@@ -492,12 +286,6 @@ const ATTRIBUTION_KINDS = [
492
286
  //#endregion
493
287
  //#region src/events/git-user.ts
494
288
  const execFileAsync$1 = promisify(execFile);
495
- /**
496
- * The git user.email of the actor, used as the envelope `user` field (decision
497
- * \#53). Reads `git config user.email` in the repo (covers local + global), the
498
- * same value `git` itself stamps on commits. Returns `null` when no email is
499
- * configured — callers convert that into a friendly error or block at SessionStart.
500
- */
501
289
  const readGitUserEmail = async (cwd) => {
502
290
  try {
503
291
  const { stdout } = await execFileAsync$1("git", ["config", "user.email"], { cwd });
@@ -509,27 +297,11 @@ const readGitUserEmail = async (cwd) => {
509
297
  };
510
298
  //#endregion
511
299
  //#region src/events/schemas.ts
512
- /**
513
- * UTC ISO 8601 timestamp with a literal `Z` suffix — no local offsets allowed
514
- * (durable rule, decision #25/#27). Accepts millisecond precision optionally:
515
- * `2026-05-28T14:30:00Z` and `2026-05-28T14:30:00.000Z` both pass.
516
- */
517
300
  const ISO_Z_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
518
301
  const isoTimestamp = () => z.string().regex(ISO_Z_REGEX);
519
302
  const nonEmpty = () => z.string().min(1);
520
- /**
521
- * Bounded free-form strings. Caps are aligned with the schema doc's
522
- * recommendations ("one line", "≤ 200 chars recommended") and stay well
523
- * below `PIPE_BUF` (4096 B) so a single event line remains atomically
524
- * `O_APPEND`-able.
525
- */
526
303
  const shortText = () => z.string().min(1).max(200);
527
304
  const oneLineText = () => z.string().min(1).max(500);
528
- /**
529
- * Envelope every event line carries. `task_id` is optional here; the per-type
530
- * schemas that need it override it to required via `.extend()`. The Zod schema
531
- * mirrors `catalog/schemas/tier2-events.md` (authority — the doc wins on conflict).
532
- */
533
305
  const envelopeBase = z.object({
534
306
  ts: isoTimestamp(),
535
307
  user: nonEmpty(),
@@ -537,13 +309,6 @@ const envelopeBase = z.object({
537
309
  task_id: nonEmpty().optional(),
538
310
  harness_version: nonEmpty()
539
311
  });
540
- /**
541
- * Optional attribution fields (#217) shared by the two friction events
542
- * (`review_rejected`, `step_completed`) — they let a line name the component the
543
- * friction is attributed to. `attributed_kind` is enum-validated; `attributed_name`
544
- * is a free string this phase (measure-then-decide, see `ATTRIBUTION_KINDS`). Both
545
- * optional so historical lines and "can't attribute" emits still validate.
546
- */
547
312
  const attributionFields = {
548
313
  attributed_kind: z.enum(ATTRIBUTION_KINDS).optional(),
549
314
  attributed_name: shortText().optional()
@@ -608,11 +373,6 @@ const stepCompletedSchema = envelopeBase.extend({
608
373
  checkpoint_result: z.enum(CHECKPOINT_RESULTS),
609
374
  ...attributionFields
610
375
  }).strict();
611
- /**
612
- * The on-wire shape of any event line, discriminated by `type`. New types extend
613
- * the file forward-compatibly: a reader unknown to the new type simply skips the
614
- * line on dispatch.
615
- */
616
376
  const eventSchema = z.discriminatedUnion("type", [
617
377
  sessionClosedSchema,
618
378
  specCreatedSchema,
@@ -626,7 +386,6 @@ const eventSchema = z.discriminatedUnion("type", [
626
386
  ]);
627
387
  //#endregion
628
388
  //#region src/events/emit.ts
629
- /** Per-type fields the CLI must coerce from `--key=value` strings to numbers. */
630
389
  const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
631
390
  "session_active_h",
632
391
  "requirements",
@@ -639,11 +398,9 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
639
398
  "steps",
640
399
  "review_iterations"
641
400
  ]);
642
- /** Per-type fields the CLI must coerce from `--key=value` strings to booleans. */
643
401
  const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
644
402
  const EVENTS_RELPATH$1 = join(".claude", "state", "events.jsonl");
645
403
  const isEventType = (value) => EVENT_TYPES.includes(value);
646
- /** Coerce a raw `--key=value` string to its declared scalar type. */
647
404
  const coerceField = (key, raw) => {
648
405
  if (NUMERIC_FIELDS.has(key)) {
649
406
  const num = Number(raw);
@@ -657,15 +414,6 @@ const coerceField = (key, raw) => {
657
414
  }
658
415
  return raw;
659
416
  };
660
- /**
661
- * Parse `--key=value` and `--key value` flag pairs into a record. Returns the
662
- * fields keyed by their (snake_case, kebab→underscore) names. Values are coerced
663
- * according to their declared types; unknown keys pass through here as raw strings
664
- * but are then **rejected** by the per-type `.strict()` schema in `runEmit` — so a
665
- * misspelled flag fails loud rather than being silently persisted into
666
- * `events.jsonl` (P7/S3 #82). A genuinely new field is added by extending the
667
- * schema, not by relaxing this parser.
668
- */
669
417
  const parseFlags = (args) => {
670
418
  const result = {};
671
419
  for (let i = 0; i < args.length; i++) {
@@ -679,14 +427,6 @@ const parseFlags = (args) => {
679
427
  }
680
428
  return result;
681
429
  };
682
- /**
683
- * Run `lemony emit <type> [--field=value …]`: build the envelope, merge
684
- * per-type fields, Zod-validate against the discriminated union, and append the
685
- * resulting line atomically to `.claude/state/events.jsonl`.
686
- *
687
- * Returns the validated event so callers (and tests) can inspect what was
688
- * written.
689
- */
690
430
  const runEmit = async (options) => {
691
431
  const { repoRoot, harnessVersion, args } = options;
692
432
  const [rawType, ...rest] = args;
@@ -723,7 +463,6 @@ const runEmit = async (options) => {
723
463
  };
724
464
  //#endregion
725
465
  //#region src/labels/labels.constant.ts
726
- /** 8 mutually-exclusive status labels (#55c), orange→green by lifecycle order. */
727
466
  const STATUS_LABELS = [
728
467
  {
729
468
  name: "harness:status:pending",
@@ -766,7 +505,6 @@ const STATUS_LABELS = [
766
505
  description: "Managed task: merged and closed."
767
506
  }
768
507
  ];
769
- /** 4 presence flags (#55d, B2.3) — no value, presence is the signal. */
770
508
  const FLAG_LABELS = [
771
509
  {
772
510
  name: "harness:managed",
@@ -786,10 +524,14 @@ const FLAG_LABELS = [
786
524
  {
787
525
  name: "harness:architecture-drift",
788
526
  color: "006b75",
789
- description: "Captured architecture.md drift (#148); resolved by the Architect at pickup, not a code change."
527
+ description: "Captured architecture.md drift; resolved by the Architect at pickup, not a code change."
528
+ },
529
+ {
530
+ name: "harness:needs-design",
531
+ color: "d4548d",
532
+ description: "Task touches UI; a design handoff (ui-handoff.md) is owed before spec-ready."
790
533
  }
791
534
  ];
792
- /** 6 discovery types (#55e), presence-based and multi-applicable, alert-red. */
793
535
  const DISCOVERY_LABELS = [
794
536
  ["T1", "contradiction"],
795
537
  ["T2", "unspecified decision"],
@@ -802,10 +544,6 @@ const DISCOVERY_LABELS = [
802
544
  color: "b60205",
803
545
  description: `Discovery (${tier}): ${meaning}.`
804
546
  }));
805
- /**
806
- * The full, canonical 18-label set. `install`/`update` create/reconcile these;
807
- * `doctor` reports drift read-only against the same list (single source of truth).
808
- */
809
547
  const HARNESS_LABELS = [
810
548
  ...STATUS_LABELS,
811
549
  ...FLAG_LABELS,
@@ -813,13 +551,6 @@ const HARNESS_LABELS = [
813
551
  ];
814
552
  //#endregion
815
553
  //#region src/labels/diff-labels.ts
816
- /**
817
- * Diff the desired `harness:*` labels against the repo's existing labels by
818
- * name. A present label whose color or description differs is `drifted`. Color
819
- * is compared case-insensitively because GitHub echoes hex in a normalized case
820
- * that needn't match our literal; description is compared exactly. Pure — all
821
- * `gh` I/O lives in the caller, so this is trivially testable without a network.
822
- */
823
554
  const diffLabels = (existing, desired) => {
824
555
  const byName = new Map(existing.map((label) => [label.name, label]));
825
556
  const missing = [];
@@ -839,16 +570,6 @@ const diffLabels = (existing, desired) => {
839
570
  };
840
571
  //#endregion
841
572
  //#region src/labels/sync-labels.ts
842
- /**
843
- * Reconcile the repo's `harness:*` labels with the canonical set via `gh label`
844
- * porcelain (decision B2.1/B2.2). Lists once, diffs (the shared pure `diffLabels`),
845
- * then creates missing and edits drifted labels. **Non-fatal by contract**: a
846
- * placeholder slug short-circuits (`skipped`) and any `gh` failure returns
847
- * `failed` with a reason — the caller (`install`) warns and points at `doctor`
848
- * rather than aborting the whole install over a recoverable step. Porcelain
849
- * (`gh label list/create/edit`) is chosen over raw `gh api` so we don't hand-roll
850
- * status-code handling.
851
- */
852
573
  const syncLabels = async (repoSlug, provider, desired = HARNESS_LABELS) => {
853
574
  if (repoSlug === "OWNER/REPO") return {
854
575
  status: "skipped",
@@ -920,22 +641,6 @@ const failureReason$2 = (action, result) => {
920
641
  };
921
642
  //#endregion
922
643
  //#region src/labels/ensure-labels.ts
923
- /**
924
- * Preflight self-heal for the verbs that add `harness:*` labels to issues (`spinoff`,
925
- * `discovery`) — ADR 0013. A fresh install whose label sync was skipped or failed (e.g.
926
- * `gh` unauthenticated at install time, then authenticated later) leaves the repo
927
- * installed-but-labels-missing; the first verb that does `gh issue create/edit --label`
928
- * then trips on a "label not found" mid-flow. Running {@link syncLabels} before the
929
- * mutation reconciles the canonical 18 labels on the fly, turning that failure into a
930
- * non-event without the user having to run `doctor`/`repair` by hand.
931
- *
932
- * **Best-effort by contract**: it never throws and never aborts the verb. `syncLabels`
933
- * already encodes its outcome in a {@link SyncLabelsResult} (a placeholder slug ⇒
934
- * `skipped`, a `gh` error ⇒ `failed`); the surrounding try/catch only guards the
935
- * unlikely case of the command runner rejecting. When the self-heal cannot help (gh
936
- * still unauthenticated at verb time), the verb proceeds and falls back to its own
937
- * load-bearing error, which points at `doctor`/`repair`.
938
- */
939
644
  const ensureLabels = async (repoSlug, provider) => {
940
645
  try {
941
646
  return await syncLabels(repoSlug, provider);
@@ -951,17 +656,6 @@ const ensureLabels = async (repoSlug, provider) => {
951
656
  };
952
657
  //#endregion
953
658
  //#region src/labels/delete-labels.ts
954
- /**
955
- * Delete the `harness:*` labels from the task-storage repo via `gh label delete`.
956
- * Opt-in (`uninstall --labels`) because labels live on the remote and may be
957
- * attached to live issues. Never throws — returns a status the caller surfaces,
958
- * symmetric to `syncLabels`.
959
- *
960
- * `gh label delete --yes` is idempotent from our side: a non-zero exit for an
961
- * already-absent label is benign and simply doesn't count toward `deleted`. A
962
- * missing `gh` (exit 127) skips the whole pass cleanly — the uninstall still
963
- * succeeded, the labels are just left for the user to remove.
964
- */
965
659
  const deleteLabels = async (taskStorageRepo, provider) => {
966
660
  if (taskStorageRepo === "OWNER/REPO") return {
967
661
  status: "skipped",
@@ -986,39 +680,17 @@ const deleteLabels = async (taskStorageRepo, provider) => {
986
680
  };
987
681
  //#endregion
988
682
  //#region src/labels/format-labels.ts
989
- /**
990
- * Lines reporting the harness:* label reconciliation outcome (decision B2.2),
991
- * for `install`/`update`/`repair`. An empty array means "print nothing" — the
992
- * null result (the verb never touched labels). On a non-fatal `skipped`/`failed`
993
- * the install still succeeded, so it reports two lines: the reason and the fix.
994
- */
995
683
  const formatLabelSync = (sync) => {
996
684
  if (!sync) return [];
997
685
  if (sync.status === "synced") return [sync.created.length + sync.updated.length === 0 ? "Labels already in sync." : `Labels synced: ${sync.created.length} created, ${sync.updated.length} updated.`];
998
686
  return [`Labels not synced (${sync.status}): ${sync.reason}`, "Run `lemony doctor` to re-check once resolved."];
999
687
  };
1000
- /**
1001
- * Lines reporting a verb's best-effort label preflight (ADR 0013, `spinoff`/`discovery`).
1002
- * Unlike {@link formatLabelSync} it stays silent unless the preflight actually created or
1003
- * edited a label — the common case (labels already in sync) must not add noise to every
1004
- * verb run. A fully failed/skipped preflight (nothing changed) is also silent: the verb
1005
- * proceeds and its own load-bearing error (which points at doctor/repair) covers the
1006
- * unrecoverable case. A **partial** failure (some labels synced, then a `gh` call errored)
1007
- * does report — the "Synced N" line alone would otherwise read as a clean success, so it
1008
- * carries a warning.
1009
- */
1010
688
  const formatLabelSelfHeal = (sync) => {
1011
689
  if (!sync) return [];
1012
690
  if (sync.created.length + sync.updated.length === 0) return [];
1013
691
  const partialFailure = sync.status === "failed" ? " (warning: some harness:* labels could not be synced — run `lemony doctor`)" : "";
1014
692
  return [`Synced ${sync.created.length} missing and ${sync.updated.length} drifted harness:* labels before continuing.${partialFailure}`];
1015
693
  };
1016
- /**
1017
- * Lines reporting the `uninstall` label outcome — symmetric to {@link formatLabelSync}.
1018
- * When `--labels` was not requested the labels are left in place (they live on remote
1019
- * issues) with a hint to re-run. A null deletion (requested but the pass never ran) is
1020
- * silent. The indentation matches the surrounding uninstall summary block.
1021
- */
1022
694
  const formatLabelDeletion = (deletion, requested) => {
1023
695
  if (!requested) return [" Labels left in place (they live on remote issues). Re-run `uninstall --labels` to delete the harness:* labels."];
1024
696
  if (!deletion) return [];
@@ -1141,26 +813,13 @@ const createTaskTrackerProvider = (type, runCommand) => {
1141
813
  };
1142
814
  //#endregion
1143
815
  //#region src/discovery/discovery.constant.ts
1144
- /**
1145
- * The status label a paused task wears while a discovery is open. Standalone (no
1146
- * `typeof`-derived type backs it), so it lives here rather than in `discovery.model.ts`.
1147
- * Mirrors `labels.model.ts`'s taxonomy — a guard test asserts the alignment.
1148
- */
1149
816
  const PAUSED_STATUS_LABEL = "harness:status:paused-for-clarification";
1150
817
  //#endregion
1151
818
  //#region src/discovery/build-labels.ts
1152
- /**
1153
- * Pure builders for the `harness:*` label names the discovery verb toggles. A
1154
- * verb-object logic module of its own (`build-labels`), kept out of `discovery.ts`
1155
- * (the verb orchestration) and `discovery.constant.ts` (values-only).
1156
- */
1157
- /** Build the full status label name from its suffix. */
1158
819
  const buildStatusLabel = (status) => `harness:status:${status}`;
1159
- /** Build the full discovery flag name from its tier. */
1160
820
  const buildDiscoveryLabel = (tier) => `harness:discovery:${tier}`;
1161
821
  //#endregion
1162
822
  //#region src/discovery/discovery.model.ts
1163
- /** The six discovery tiers (T1–T6); the issue carries `harness:discovery:T<n>`. */
1164
823
  const DISCOVERY_TIERS = [
1165
824
  "T1",
1166
825
  "T2",
@@ -1169,12 +828,6 @@ const DISCOVERY_TIERS = [
1169
828
  "T5",
1170
829
  "T6"
1171
830
  ];
1172
- /**
1173
- * The mutually-exclusive status a task may be paused from / resumed to (`paused_from`).
1174
- * Only the three active-work phases — discoveries are raised during spec authoring,
1175
- * implementation, or review, never from `pending`/`spec-ready`/`done`. These must stay a
1176
- * subset of `labels.model.ts`'s status labels; a guard test asserts the alignment.
1177
- */
1178
831
  const RESUMABLE_STATUSES = [
1179
832
  "spec-in-progress",
1180
833
  "in-progress",
@@ -1182,18 +835,6 @@ const RESUMABLE_STATUSES = [
1182
835
  ];
1183
836
  //#endregion
1184
837
  //#region src/discovery/discovery.ts
1185
- /**
1186
- * Reflect a raised/resolved discovery onto its GitHub issue (#109): flip the status
1187
- * label, toggle the `harness:discovery:T<n>` flag, and (on pause) post a comment.
1188
- * Encodes the protocol so the `resolve-discovery` skill stays thin.
1189
- *
1190
- * Two tiers of strictness (ADR 0007): the **label transition is fail-loud** — a failed
1191
- * label op stops the run and returns `ok:false` (the CLI exits 1), because a label is
1192
- * load-bearing, unlike telemetry's fire-and-forget emit. The **pause comment is
1193
- * best-effort** — if it fails the labels still stand (`ok:true`) but `warning` is set
1194
- * and surfaced (visible, not silent). That split also makes a retry safe: a retry after
1195
- * a comment failure re-applies only the idempotent labels, never a duplicate comment.
1196
- */
1197
838
  const runDiscovery = async (inputs) => {
1198
839
  const action = parseAction(inputs.action);
1199
840
  const taskId = requireFlag$1(inputs.taskId, "task-id");
@@ -1242,7 +883,6 @@ const runDiscovery = async (inputs) => {
1242
883
  labelSync
1243
884
  };
1244
885
  };
1245
- /** Pause: leave the current status, enter paused, flag the tier. */
1246
886
  const pauseLabelSteps = (provider, ref, tier, status) => [
1247
887
  {
1248
888
  label: `removed ${buildStatusLabel(status)}`,
@@ -1257,7 +897,6 @@ const pauseLabelSteps = (provider, ref, tier, status) => [
1257
897
  run: () => provider.addLabel(ref, buildDiscoveryLabel(tier))
1258
898
  }
1259
899
  ];
1260
- /** Resume: clear the tier flag, leave paused, restore the prior status. */
1261
900
  const resumeLabelSteps = (provider, ref, tier, status) => [
1262
901
  {
1263
902
  label: `removed ${buildDiscoveryLabel(tier)}`,
@@ -1298,55 +937,1099 @@ const failureReason$1 = (result) => {
1298
937
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
1299
938
  };
1300
939
  //#endregion
940
+ //#region src/fs/exists.ts
941
+ const pathExists = async (path) => {
942
+ try {
943
+ await stat(path);
944
+ return true;
945
+ } catch {
946
+ return false;
947
+ }
948
+ };
949
+ //#endregion
950
+ //#region src/fs/list-files.ts
951
+ const listFiles = async (dir) => {
952
+ const entries = await readdir(dir, { withFileTypes: true });
953
+ return (await Promise.all(entries.map(async (entry) => {
954
+ if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
955
+ return [entry.name];
956
+ }))).flat();
957
+ };
958
+ //#endregion
959
+ //#region src/fs/prune-empty-dirs.ts
960
+ const pruneEmptyDirs = async (repoRoot, removedFiles) => {
961
+ const dirs = /* @__PURE__ */ new Set();
962
+ for (const relPath of removedFiles) {
963
+ let dir = dirname(relPath);
964
+ while (dir !== "." && dir !== sep) {
965
+ dirs.add(dir);
966
+ dir = dirname(dir);
967
+ }
968
+ }
969
+ const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
970
+ for (const dir of ordered) try {
971
+ await rmdir(join(repoRoot, dir));
972
+ } catch {}
973
+ };
974
+ //#endregion
975
+ //#region src/fs/write-managed.ts
976
+ const writeManaged = async (root, relPath, content, executable) => {
977
+ const dest = join(root, relPath);
978
+ await mkdir(dirname(dest), { recursive: true });
979
+ await writeFile(dest, content);
980
+ await chmod(dest, executable ? 493 : 420);
981
+ };
982
+ //#endregion
983
+ //#region src/fs/symlink-guard.ts
984
+ const assertNoSymlinkTraversal = async (root, relPaths) => {
985
+ const components = /* @__PURE__ */ new Set();
986
+ for (const relPath of relPaths) {
987
+ let current = "";
988
+ for (const part of relPath.split(sep)) {
989
+ if (part.length === 0) continue;
990
+ current = current ? join(current, part) : part;
991
+ components.add(current);
992
+ }
993
+ }
994
+ const offenders = (await Promise.all([...components].map(async (rel) => {
995
+ try {
996
+ return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
997
+ } catch {
998
+ return null;
999
+ }
1000
+ }))).filter((rel) => rel !== null);
1001
+ 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"));
1002
+ };
1003
+ //#endregion
1004
+ //#region src/design-tokens/design-tokens.constant.ts
1005
+ const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
1006
+ const SCANNED_EXTENSIONS = [
1007
+ ".css",
1008
+ ".scss",
1009
+ ".sass",
1010
+ ".less",
1011
+ ".styl",
1012
+ ".pcss",
1013
+ ".postcss",
1014
+ ".ts",
1015
+ ".tsx",
1016
+ ".js",
1017
+ ".jsx",
1018
+ ".mjs",
1019
+ ".cjs",
1020
+ ".vue",
1021
+ ".svelte",
1022
+ ".astro",
1023
+ ".mdx",
1024
+ ".html"
1025
+ ];
1026
+ const STYLESHEET_EXTENSIONS = [
1027
+ ".css",
1028
+ ".scss",
1029
+ ".sass",
1030
+ ".less",
1031
+ ".styl",
1032
+ ".pcss",
1033
+ ".postcss"
1034
+ ];
1035
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([
1036
+ "node_modules",
1037
+ ".git",
1038
+ "dist",
1039
+ "build",
1040
+ "out",
1041
+ "coverage",
1042
+ ".next",
1043
+ ".cache",
1044
+ ".claude"
1045
+ ]);
1046
+ //#endregion
1047
+ //#region src/design-tokens/design-tokens.model.ts
1048
+ const TOKEN_TIERS = [
1049
+ "primitive",
1050
+ "semantic",
1051
+ "component"
1052
+ ];
1053
+ //#endregion
1054
+ //#region src/design-tokens/token-parse.ts
1055
+ const aliasTarget = (value) => {
1056
+ if (typeof value !== "string") return void 0;
1057
+ const match = /^\{([^{}\s]+)\}$/.exec(value.trim());
1058
+ return match ? match[1] : void 0;
1059
+ };
1060
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1061
+ //#endregion
1062
+ //#region src/design-tokens/design-tokens.ts
1063
+ const runValidate = async (inputs) => {
1064
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1065
+ if (!await pathExists(tokenPath)) return {
1066
+ ok: true,
1067
+ tokensFound: false,
1068
+ tokenCount: 0,
1069
+ filesScanned: 0,
1070
+ violations: []
1071
+ };
1072
+ const fileCheck = validateTokenFile(await readFile(tokenPath, "utf8"));
1073
+ if (!fileCheck.ok) return {
1074
+ ok: false,
1075
+ tokensFound: true,
1076
+ tokenCount: fileCheck.tokenCount,
1077
+ filesScanned: 0,
1078
+ violations: fileCheck.violations
1079
+ };
1080
+ const scanRoot = inputs.scanDir ? join(inputs.repoRoot, inputs.scanDir) : inputs.repoRoot;
1081
+ const extensions = mergeExtensions(inputs.extraExtensions);
1082
+ const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1083
+ return {
1084
+ ok: sourceViolations.violations.length === 0,
1085
+ tokensFound: true,
1086
+ tokenCount: fileCheck.tokenCount,
1087
+ filesScanned: sourceViolations.filesScanned,
1088
+ violations: sourceViolations.violations
1089
+ };
1090
+ };
1091
+ const validateTokenFile = (raw) => {
1092
+ let parsed;
1093
+ try {
1094
+ parsed = JSON.parse(raw);
1095
+ } catch (error) {
1096
+ return {
1097
+ ok: false,
1098
+ tokenCount: 0,
1099
+ violations: [{
1100
+ kind: "invalid-json",
1101
+ message: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
1102
+ }]
1103
+ };
1104
+ }
1105
+ if (!isRecord(parsed)) return {
1106
+ ok: false,
1107
+ tokenCount: 0,
1108
+ violations: [{
1109
+ kind: "malformed-token-file",
1110
+ message: `${DESIGN_TOKENS_FILE} must be a JSON object with primitive/semantic/component tiers.`
1111
+ }]
1112
+ };
1113
+ const violations = [];
1114
+ for (const key of Object.keys(parsed)) {
1115
+ if (key.startsWith("$")) continue;
1116
+ if (!TOKEN_TIERS.includes(key)) violations.push({
1117
+ kind: "malformed-token-file",
1118
+ message: `unknown top-level group "${key}" — only ${TOKEN_TIERS.join("/")} (and $-prefixed metadata) are allowed at the top level.`
1119
+ });
1120
+ }
1121
+ const tokens = /* @__PURE__ */ new Map();
1122
+ for (const tier of TOKEN_TIERS) {
1123
+ const group = parsed[tier];
1124
+ if (group === void 0) continue;
1125
+ if (!isRecord(group)) {
1126
+ violations.push({
1127
+ kind: "malformed-token-file",
1128
+ message: `tier "${tier}" must be an object of token groups.`
1129
+ });
1130
+ continue;
1131
+ }
1132
+ collectTokens$1(group, tier, tier, tokens, violations);
1133
+ }
1134
+ violations.push(...collectAliasViolations(tokens));
1135
+ return {
1136
+ ok: violations.length === 0,
1137
+ tokenCount: tokens.size,
1138
+ violations
1139
+ };
1140
+ };
1141
+ const collectTokens$1 = (node, tier, path, out, violations) => {
1142
+ for (const [key, child] of Object.entries(node)) {
1143
+ if (key.startsWith("$")) continue;
1144
+ const childPath = `${path}.${key}`;
1145
+ if (!isRecord(child)) {
1146
+ violations.push({
1147
+ kind: "malformed-token-file",
1148
+ message: `"${childPath}" must be a token object or a group, not a bare value.`
1149
+ });
1150
+ continue;
1151
+ }
1152
+ if ("$value" in child) {
1153
+ out.set(childPath, {
1154
+ tier,
1155
+ value: child.$value
1156
+ });
1157
+ const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
1158
+ if (stray.length > 0) violations.push({
1159
+ kind: "malformed-token-file",
1160
+ message: `token "${childPath}" has both a $value and nested group(s) (${stray.join(", ")}) — a token cannot also be a group.`
1161
+ });
1162
+ } else collectTokens$1(child, tier, childPath, out, violations);
1163
+ }
1164
+ };
1165
+ const collectAliasViolations = (tokens) => {
1166
+ const violations = [];
1167
+ for (const [path, token] of tokens) {
1168
+ const alias = aliasTarget(token.value);
1169
+ if (alias === void 0) continue;
1170
+ if (token.tier === "primitive") {
1171
+ violations.push({
1172
+ kind: "primitive-not-literal",
1173
+ message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
1174
+ });
1175
+ continue;
1176
+ }
1177
+ if (!tokens.has(alias)) {
1178
+ violations.push({
1179
+ kind: "unresolved-alias",
1180
+ message: `token "${path}" references "{${alias}}", which does not exist.`
1181
+ });
1182
+ continue;
1183
+ }
1184
+ if (aliasChainCycles(path, tokens)) violations.push({
1185
+ kind: "cyclic-alias",
1186
+ message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1187
+ });
1188
+ }
1189
+ return violations;
1190
+ };
1191
+ const aliasChainCycles = (start, tokens) => {
1192
+ const seen = /* @__PURE__ */ new Set();
1193
+ let current = start;
1194
+ while (current !== void 0) {
1195
+ if (seen.has(current)) return true;
1196
+ seen.add(current);
1197
+ const token = tokens.get(current);
1198
+ if (token === void 0) return false;
1199
+ current = aliasTarget(token.value);
1200
+ }
1201
+ return false;
1202
+ };
1203
+ const mergeExtensions = (extra) => {
1204
+ const set = new Set(SCANNED_EXTENSIONS);
1205
+ for (const raw of extra ?? []) {
1206
+ const lower = raw.trim().toLowerCase();
1207
+ if (lower.length === 0) continue;
1208
+ set.add(lower.startsWith(".") ? lower : `.${lower}`);
1209
+ }
1210
+ return set;
1211
+ };
1212
+ const scanSource = async (repoRoot, scanRoot, extensions) => {
1213
+ if (!await pathExists(scanRoot)) return {
1214
+ filesScanned: 0,
1215
+ violations: []
1216
+ };
1217
+ const files = (await collectSourceFiles(scanRoot, extensions)).toSorted();
1218
+ const violations = [];
1219
+ let filesScanned = 0;
1220
+ for (const file of files) {
1221
+ const content = await readFile(file, "utf8").catch(() => void 0);
1222
+ if (content === void 0) continue;
1223
+ filesScanned += 1;
1224
+ const relPath = relative(repoRoot, file).split(sep).join("/");
1225
+ violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
1226
+ }
1227
+ return {
1228
+ filesScanned,
1229
+ violations
1230
+ };
1231
+ };
1232
+ const collectSourceFiles = async (dir, extensions) => {
1233
+ const entries = await readdir(dir, { withFileTypes: true });
1234
+ return (await Promise.all(entries.map(async (entry) => {
1235
+ const full = join(dir, entry.name);
1236
+ if (entry.isDirectory()) {
1237
+ if (IGNORED_DIRS.has(entry.name)) return [];
1238
+ return collectSourceFiles(full, extensions);
1239
+ }
1240
+ return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
1241
+ }))).flat();
1242
+ };
1243
+ const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/;
1244
+ const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
1245
+ const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
1246
+ const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
1247
+ const scanFile = (relPath, ext, content) => {
1248
+ const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
1249
+ const violations = [];
1250
+ content.split("\n").forEach((line, index) => {
1251
+ if (line.includes("design-tokens-allow")) return;
1252
+ const lineNo = index + 1;
1253
+ if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
1254
+ kind: "hardcoded-color",
1255
+ message: `raw color literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
1256
+ file: relPath,
1257
+ line: lineNo
1258
+ });
1259
+ if (isStylesheet && hasRawDimension(line)) violations.push({
1260
+ kind: "hardcoded-dimension",
1261
+ message: `raw dimension literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
1262
+ file: relPath,
1263
+ line: lineNo
1264
+ });
1265
+ });
1266
+ return violations;
1267
+ };
1268
+ const hasRawDimension = (line) => {
1269
+ for (const match of line.matchAll(PX_DIMENSION)) {
1270
+ const value = match[1];
1271
+ if (value !== void 0 && !EXEMPT_DIMENSIONS.has(value)) return true;
1272
+ }
1273
+ return false;
1274
+ };
1275
+ //#endregion
1276
+ //#region src/design-tokens/color-contrast.ts
1277
+ const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
1278
+ const parseColor = (raw) => {
1279
+ const value = raw.trim();
1280
+ if (value.startsWith("#")) return parseHex(value);
1281
+ const fn = /^(rgba?|hsla?)\(\s*(.*?)\s*\)$/i.exec(value);
1282
+ if (!fn) return void 0;
1283
+ const kind = fn[1]?.toLowerCase() ?? "";
1284
+ const body = fn[2] ?? "";
1285
+ return kind.startsWith("rgb") ? parseRgb(body) : parseHsl(body);
1286
+ };
1287
+ const expandHex = (s) => s.split("").map((c) => c + c).join("");
1288
+ const parseHex = (hex) => {
1289
+ const h = hex.slice(1);
1290
+ if (!/^[0-9a-fA-F]+$/.test(h)) return void 0;
1291
+ const full = h.length === 3 || h.length === 4 ? expandHex(h) : h.length === 6 || h.length === 8 ? h : void 0;
1292
+ if (full === void 0) return void 0;
1293
+ return {
1294
+ r: parseInt(full.slice(0, 2), 16),
1295
+ g: parseInt(full.slice(2, 4), 16),
1296
+ b: parseInt(full.slice(4, 6), 16),
1297
+ a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1
1298
+ };
1299
+ };
1300
+ const splitBody = (body) => body.replace(/\//g, " ").split(/[\s,]+/).map((token) => token.trim()).filter((token) => token.length > 0);
1301
+ const parseChannel = (token) => {
1302
+ if (token.endsWith("%")) {
1303
+ const pct = Number(token.slice(0, -1));
1304
+ return Number.isFinite(pct) ? clamp(pct / 100, 0, 1) * 255 : void 0;
1305
+ }
1306
+ const n = Number(token);
1307
+ return Number.isFinite(n) ? clamp(n, 0, 255) : void 0;
1308
+ };
1309
+ const parseAlpha = (token) => {
1310
+ if (token.endsWith("%")) {
1311
+ const pct = Number(token.slice(0, -1));
1312
+ return Number.isFinite(pct) ? clamp(pct / 100, 0, 1) : 1;
1313
+ }
1314
+ const n = Number(token);
1315
+ return Number.isFinite(n) ? clamp(n, 0, 1) : 1;
1316
+ };
1317
+ const parseRgb = (body) => {
1318
+ const parts = splitBody(body);
1319
+ if (parts.length < 3) return void 0;
1320
+ const r = parseChannel(parts[0] ?? "");
1321
+ const g = parseChannel(parts[1] ?? "");
1322
+ const b = parseChannel(parts[2] ?? "");
1323
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
1324
+ const a = parts[3] !== void 0 ? parseAlpha(parts[3]) : 1;
1325
+ return {
1326
+ r: Math.round(r),
1327
+ g: Math.round(g),
1328
+ b: Math.round(b),
1329
+ a
1330
+ };
1331
+ };
1332
+ const parseHsl = (body) => {
1333
+ const parts = splitBody(body);
1334
+ if (parts.length < 3) return void 0;
1335
+ const h = Number((parts[0] ?? "").replace(/deg$/i, ""));
1336
+ const s = parsePercent(parts[1] ?? "");
1337
+ const l = parsePercent(parts[2] ?? "");
1338
+ if (!Number.isFinite(h) || s === void 0 || l === void 0) return void 0;
1339
+ const a = parts[3] !== void 0 ? parseAlpha(parts[3]) : 1;
1340
+ return {
1341
+ ...hslToRgb((h % 360 + 360) % 360, s, l),
1342
+ a
1343
+ };
1344
+ };
1345
+ const parsePercent = (token) => {
1346
+ const n = Number(token.endsWith("%") ? token.slice(0, -1) : token);
1347
+ return Number.isFinite(n) ? clamp(n / 100, 0, 1) : void 0;
1348
+ };
1349
+ const hslToRgb = (h, s, l) => {
1350
+ const c = (1 - Math.abs(2 * l - 1)) * s;
1351
+ const x = c * (1 - Math.abs(h / 60 % 2 - 1));
1352
+ const m = l - c / 2;
1353
+ const [r1, g1, b1] = h < 60 ? [
1354
+ c,
1355
+ x,
1356
+ 0
1357
+ ] : h < 120 ? [
1358
+ x,
1359
+ c,
1360
+ 0
1361
+ ] : h < 180 ? [
1362
+ 0,
1363
+ c,
1364
+ x
1365
+ ] : h < 240 ? [
1366
+ 0,
1367
+ x,
1368
+ c
1369
+ ] : h < 300 ? [
1370
+ x,
1371
+ 0,
1372
+ c
1373
+ ] : [
1374
+ c,
1375
+ 0,
1376
+ x
1377
+ ];
1378
+ return {
1379
+ r: Math.round((r1 + m) * 255),
1380
+ g: Math.round((g1 + m) * 255),
1381
+ b: Math.round((b1 + m) * 255)
1382
+ };
1383
+ };
1384
+ const WHITE = {
1385
+ r: 255,
1386
+ g: 255,
1387
+ b: 255,
1388
+ a: 1
1389
+ };
1390
+ const flatten = (color, backdrop) => ({
1391
+ r: color.r * color.a + backdrop.r * (1 - color.a),
1392
+ g: color.g * color.a + backdrop.g * (1 - color.a),
1393
+ b: color.b * color.a + backdrop.b * (1 - color.a),
1394
+ a: 1
1395
+ });
1396
+ const linearizeChannel = (channel) => {
1397
+ const s = channel / 255;
1398
+ return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
1399
+ };
1400
+ const relativeLuminance = ({ r, g, b }) => .2126 * linearizeChannel(r) + .7152 * linearizeChannel(g) + .0722 * linearizeChannel(b);
1401
+ const contrastRatio = (foreground, background) => {
1402
+ const bg = background.a < 1 ? flatten(background, WHITE) : background;
1403
+ const l1 = relativeLuminance(foreground.a < 1 ? flatten(foreground, bg) : foreground);
1404
+ const l2 = relativeLuminance(bg);
1405
+ const light = Math.max(l1, l2);
1406
+ const dark = Math.min(l1, l2);
1407
+ return Math.round((light + .05) / (dark + .05) * 100) / 100;
1408
+ };
1409
+ //#endregion
1410
+ //#region src/design-tokens/contrast.constant.ts
1411
+ const CONTRAST_EXTENSION = "com.lemony.contrast";
1412
+ const MODES_EXTENSION = "com.lemony.modes";
1413
+ const BASE_MODE = "base";
1414
+ const DEFAULT_LEVEL = "text";
1415
+ const WCAG_FLOORS = {
1416
+ text: 4.5,
1417
+ large: 3,
1418
+ "non-text": 3
1419
+ };
1420
+ //#endregion
1421
+ //#region src/design-tokens/contrast.model.ts
1422
+ const CONTRAST_LEVELS = [
1423
+ "text",
1424
+ "large",
1425
+ "non-text"
1426
+ ];
1427
+ //#endregion
1428
+ //#region src/design-tokens/contrast.ts
1429
+ const runContrast = async (inputs) => {
1430
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1431
+ if (!await pathExists(tokenPath)) return emptyResult$1(false);
1432
+ const raw = await readFile(tokenPath, "utf8");
1433
+ let parsed;
1434
+ try {
1435
+ parsed = JSON.parse(raw);
1436
+ } catch (error) {
1437
+ return {
1438
+ ...emptyResult$1(true),
1439
+ ok: false,
1440
+ problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1441
+ };
1442
+ }
1443
+ if (!isRecord(parsed)) return {
1444
+ ...emptyResult$1(true),
1445
+ ok: false,
1446
+ problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
1447
+ };
1448
+ const tokens = collectTokens(parsed);
1449
+ const problems = [];
1450
+ const pairs = measurePairs(discoverPairs(tokens, problems), declaredModes(tokens), tokens);
1451
+ return {
1452
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1453
+ tokensFound: true,
1454
+ pairsChecked: pairs.length,
1455
+ pairs,
1456
+ problems
1457
+ };
1458
+ };
1459
+ const emptyResult$1 = (tokensFound) => ({
1460
+ ok: true,
1461
+ tokensFound,
1462
+ pairsChecked: 0,
1463
+ pairs: [],
1464
+ problems: []
1465
+ });
1466
+ const collectTokens = (parsed) => {
1467
+ const out = /* @__PURE__ */ new Map();
1468
+ const walk = (node, tier, path) => {
1469
+ for (const [key, child] of Object.entries(node)) {
1470
+ if (key.startsWith("$") || !isRecord(child)) continue;
1471
+ const childPath = `${path}.${key}`;
1472
+ if ("$value" in child) out.set(childPath, {
1473
+ tier,
1474
+ value: child.$value,
1475
+ node: child
1476
+ });
1477
+ else walk(child, tier, childPath);
1478
+ }
1479
+ };
1480
+ for (const tier of TOKEN_TIERS) {
1481
+ const group = parsed[tier];
1482
+ if (isRecord(group)) walk(group, tier, tier);
1483
+ }
1484
+ return out;
1485
+ };
1486
+ const readExtension = (node, namespace) => {
1487
+ const extensions = node["$extensions"];
1488
+ return isRecord(extensions) ? extensions[namespace] : void 0;
1489
+ };
1490
+ const declaredModes = (tokens) => {
1491
+ const names = /* @__PURE__ */ new Set();
1492
+ for (const token of tokens.values()) {
1493
+ const modes = readExtension(token.node, MODES_EXTENSION);
1494
+ if (isRecord(modes)) {
1495
+ for (const name of Object.keys(modes)) if (name.trim().length > 0) names.add(name);
1496
+ }
1497
+ }
1498
+ return [...names].toSorted();
1499
+ };
1500
+ const discoverPairs = (tokens, problems) => {
1501
+ const merged = /* @__PURE__ */ new Map();
1502
+ const add = (spec) => {
1503
+ merged.set(`${spec.foreground}|${spec.background}`, spec);
1504
+ };
1505
+ for (const spec of conventionPairs(tokens)) add(spec);
1506
+ for (const spec of extensionPairs(tokens, problems)) add(spec);
1507
+ return [...merged.values()];
1508
+ };
1509
+ const conventionPairs = (tokens) => {
1510
+ const pairs = [];
1511
+ for (const [path, token] of tokens) {
1512
+ if (token.tier !== "semantic") continue;
1513
+ const segments = path.split(".");
1514
+ const leaf = segments[segments.length - 1] ?? "";
1515
+ if (!leaf.startsWith("on-") || leaf.length <= 3) continue;
1516
+ const basePath = [...segments.slice(0, -1), leaf.slice(3)].join(".");
1517
+ if (!tokens.has(basePath)) continue;
1518
+ if (!resolveColor(path, "base", tokens)) continue;
1519
+ if (!resolveColor(basePath, "base", tokens)) continue;
1520
+ pairs.push({
1521
+ foreground: path,
1522
+ background: basePath,
1523
+ level: DEFAULT_LEVEL,
1524
+ source: "convention"
1525
+ });
1526
+ }
1527
+ return pairs;
1528
+ };
1529
+ const extensionPairs = (tokens, problems) => {
1530
+ const pairs = [];
1531
+ for (const [path, token] of tokens) {
1532
+ const declaration = readExtension(token.node, CONTRAST_EXTENSION);
1533
+ if (declaration === void 0) continue;
1534
+ const entries = Array.isArray(declaration) ? declaration : [declaration];
1535
+ for (const entry of entries) {
1536
+ if (!isRecord(entry) || typeof entry["against"] !== "string") {
1537
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected { against: "{token.path}", level? }.`);
1538
+ continue;
1539
+ }
1540
+ const against = aliasTarget(entry["against"]) ?? entry["against"];
1541
+ if (against === path) {
1542
+ problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1543
+ continue;
1544
+ }
1545
+ if (!tokens.has(against)) {
1546
+ problems.push(`token "${path}" contrast-against "${entry["against"]}" does not resolve to a known token.`);
1547
+ continue;
1548
+ }
1549
+ if (!resolveColor(against, "base", tokens)) {
1550
+ problems.push(`token "${path}" contrast-against "${entry["against"]}" is not a colour token.`);
1551
+ continue;
1552
+ }
1553
+ if (!resolveColor(path, "base", tokens)) {
1554
+ problems.push(`token "${path}" declares a contrast pair but is not itself a colour token.`);
1555
+ continue;
1556
+ }
1557
+ pairs.push({
1558
+ foreground: path,
1559
+ background: against,
1560
+ level: normalizeLevel(entry["level"], path, problems),
1561
+ source: "extension"
1562
+ });
1563
+ }
1564
+ }
1565
+ return pairs;
1566
+ };
1567
+ const normalizeLevel = (raw, path, problems) => {
1568
+ if (raw === void 0) return DEFAULT_LEVEL;
1569
+ if (typeof raw === "string" && CONTRAST_LEVELS.includes(raw)) return raw;
1570
+ problems.push(`token "${path}" has an unknown contrast level "${String(raw)}" — use ${CONTRAST_LEVELS.join("/")}.`);
1571
+ return DEFAULT_LEVEL;
1572
+ };
1573
+ const measurePairs = (specs, modes, tokens) => {
1574
+ const rows = [];
1575
+ for (const spec of specs) {
1576
+ const baseFg = resolveColor(spec.foreground, BASE_MODE, tokens);
1577
+ const baseBg = resolveColor(spec.background, BASE_MODE, tokens);
1578
+ if (!baseFg || !baseBg) continue;
1579
+ rows.push(makeRow(spec, BASE_MODE, baseFg, baseBg));
1580
+ for (const mode of modes) {
1581
+ const fg = resolveColor(spec.foreground, mode, tokens) ?? baseFg;
1582
+ const bg = resolveColor(spec.background, mode, tokens) ?? baseBg;
1583
+ if (sameColor(fg, baseFg) && sameColor(bg, baseBg)) continue;
1584
+ rows.push(makeRow(spec, mode, fg, bg));
1585
+ }
1586
+ }
1587
+ return rows;
1588
+ };
1589
+ const makeRow = (spec, mode, foreground, background) => {
1590
+ const ratio = contrastRatio(foreground, background);
1591
+ const floor = WCAG_FLOORS[spec.level];
1592
+ return {
1593
+ foreground: spec.foreground,
1594
+ background: spec.background,
1595
+ mode,
1596
+ level: spec.level,
1597
+ ratio,
1598
+ floor,
1599
+ passes: ratio >= floor,
1600
+ source: spec.source
1601
+ };
1602
+ };
1603
+ const sameColor = (a, b) => a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
1604
+ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1605
+ if (seen.has(path) || seen.size >= 64) return void 0;
1606
+ seen.add(path);
1607
+ const token = tokens.get(path);
1608
+ if (!token) return void 0;
1609
+ let value = token.value;
1610
+ if (mode !== "base") {
1611
+ const modes = readExtension(token.node, MODES_EXTENSION);
1612
+ if (isRecord(modes) && modes[mode] !== void 0) value = modes[mode];
1613
+ }
1614
+ const alias = aliasTarget(value);
1615
+ if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1616
+ return typeof value === "string" ? parseColor(value) : void 0;
1617
+ };
1618
+ //#endregion
1619
+ //#region src/design-tokens/design-sync.constant.ts
1620
+ const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
1621
+ //#endregion
1622
+ //#region src/design-tokens/neutral-mapping.ts
1623
+ const KNOWN_TIERS = new Set(TOKEN_TIERS);
1624
+ const dtcgToNeutral = (parsed) => {
1625
+ const variables = [];
1626
+ for (const tier of TOKEN_TIERS) {
1627
+ const group = parsed[tier];
1628
+ if (isRecord(group)) walk(group, tier, variables);
1629
+ }
1630
+ return {
1631
+ schemaVersion: 1,
1632
+ variables: variables.toSorted(byName)
1633
+ };
1634
+ };
1635
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1636
+ const walk = (node, path, out) => {
1637
+ for (const [key, child] of Object.entries(node)) {
1638
+ if (key.startsWith("$") || !isRecord(child)) continue;
1639
+ const childPath = `${path}.${key}`;
1640
+ if ("$value" in child) out.push(toVariable$1(childPath, child));
1641
+ else walk(child, childPath, out);
1642
+ }
1643
+ };
1644
+ const toVariable$1 = (name, node) => {
1645
+ const type = typeof node["$type"] === "string" ? node["$type"] : "";
1646
+ const modes = readModes(node);
1647
+ const alias = aliasTarget(node["$value"]);
1648
+ const base = {
1649
+ name,
1650
+ type
1651
+ };
1652
+ if (modes) base.modes = modes;
1653
+ if (alias !== void 0) return {
1654
+ ...base,
1655
+ ref: alias
1656
+ };
1657
+ return {
1658
+ ...base,
1659
+ value: stringifyValue(node["$value"])
1660
+ };
1661
+ };
1662
+ const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
1663
+ const readModes = (node) => {
1664
+ const extensions = node["$extensions"];
1665
+ const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
1666
+ if (!isRecord(modes)) return void 0;
1667
+ const out = {};
1668
+ for (const [name, value] of Object.entries(modes)) if (name.trim().length > 0 && typeof value === "string") out[name] = value;
1669
+ return Object.keys(out).length > 0 ? out : void 0;
1670
+ };
1671
+ const toIncoming = (variable) => {
1672
+ const isAlias = variable.ref !== void 0;
1673
+ const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
1674
+ const type = variable.type;
1675
+ const first = variable.name.split(".")[0] ?? "";
1676
+ if (KNOWN_TIERS.has(first)) return withModes({
1677
+ path: variable.name,
1678
+ tier: first,
1679
+ type,
1680
+ value
1681
+ }, variable);
1682
+ const tier = isAlias ? "semantic" : "primitive";
1683
+ return withModes({
1684
+ path: `${tier}.${variable.name}`,
1685
+ tier,
1686
+ type,
1687
+ value
1688
+ }, variable);
1689
+ };
1690
+ const withModes = (token, variable) => variable.modes ? {
1691
+ ...token,
1692
+ modes: variable.modes
1693
+ } : token;
1694
+ const comparisonKey = (type, value, modes) => JSON.stringify({
1695
+ type,
1696
+ value,
1697
+ modes: sortedModes(modes)
1698
+ });
1699
+ const sortedModes = (modes) => {
1700
+ if (!modes) return null;
1701
+ const out = {};
1702
+ for (const name of Object.keys(modes).toSorted()) out[name] = modes[name] ?? "";
1703
+ return out;
1704
+ };
1705
+ const diffNeutralAgainstJson = (neutral, parsed) => {
1706
+ const current = /* @__PURE__ */ new Map();
1707
+ for (const variable of dtcgToNeutral(parsed).variables) current.set(variable.name, variable);
1708
+ const changes = [];
1709
+ for (const variable of neutral.variables) {
1710
+ const incoming = toIncoming(variable);
1711
+ const existing = current.get(incoming.path);
1712
+ const after = incoming.value;
1713
+ const before = existing ? neutralValue(existing) : void 0;
1714
+ changes.push({
1715
+ path: incoming.path,
1716
+ tier: incoming.tier,
1717
+ kind: classifyImport(existing, incoming, variable),
1718
+ ...before !== void 0 ? { before } : {},
1719
+ after
1720
+ });
1721
+ }
1722
+ return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1723
+ };
1724
+ const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
1725
+ const classifyImport = (existing, incoming, variable) => {
1726
+ if (!existing) return "new";
1727
+ return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, variable.modes) ? "unchanged" : "changed";
1728
+ };
1729
+ const mergeNeutralIntoJson = (neutral, parsed, only) => {
1730
+ const merged = structuredClone(parsed);
1731
+ const onlySet = only ? new Set(only) : void 0;
1732
+ const written = [];
1733
+ const skipped = [];
1734
+ for (const variable of neutral.variables) {
1735
+ const incoming = toIncoming(variable);
1736
+ if (onlySet && !onlySet.has(incoming.path)) continue;
1737
+ (setToken(merged, incoming) ? written : skipped).push(incoming.path);
1738
+ }
1739
+ return {
1740
+ merged,
1741
+ written: written.toSorted(),
1742
+ skipped: skipped.toSorted()
1743
+ };
1744
+ };
1745
+ const isTokenNode = (value) => isRecord(value) && "$value" in value;
1746
+ const hasGroupChildren = (value) => isRecord(value) && Object.keys(value).some((key) => !key.startsWith("$"));
1747
+ const setToken = (root, incoming) => {
1748
+ const segments = incoming.path.split(".");
1749
+ const leafKey = segments[segments.length - 1];
1750
+ if (!leafKey) return false;
1751
+ let probe = root;
1752
+ for (const segment of segments.slice(0, -1)) {
1753
+ if (!isRecord(probe)) break;
1754
+ const next = probe[segment];
1755
+ if (isTokenNode(next)) return false;
1756
+ probe = next;
1757
+ }
1758
+ if (isRecord(probe) && hasGroupChildren(probe[leafKey])) return false;
1759
+ let node = root;
1760
+ for (const segment of segments.slice(0, -1)) {
1761
+ const next = node[segment];
1762
+ if (!isRecord(next)) node[segment] = {};
1763
+ node = node[segment];
1764
+ }
1765
+ const leaf = { $value: incoming.value };
1766
+ if (incoming.type.length > 0) leaf["$type"] = incoming.type;
1767
+ if (incoming.modes) leaf["$extensions"] = { [MODES_EXTENSION]: incoming.modes };
1768
+ node[leafKey] = leaf;
1769
+ return true;
1770
+ };
1771
+ const upsertPlan = (parsed, toolState) => {
1772
+ const current = /* @__PURE__ */ new Map();
1773
+ for (const variable of toolState?.variables ?? []) current.set(variable.name, variable);
1774
+ const plan = [];
1775
+ for (const variable of dtcgToNeutral(parsed).variables) plan.push({
1776
+ name: variable.name,
1777
+ kind: classifyExport(variable, current)
1778
+ });
1779
+ return plan;
1780
+ };
1781
+ const classifyExport = (projected, current) => {
1782
+ const existing = current.get(projected.name);
1783
+ if (!existing) return "create";
1784
+ return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, neutralValue(existing), existing.modes) ? "unchanged" : "update";
1785
+ };
1786
+ //#endregion
1787
+ //#region src/design-tokens/sync-io.ts
1788
+ const parseNeutralFile = (raw) => {
1789
+ let parsed;
1790
+ try {
1791
+ parsed = JSON.parse(raw);
1792
+ } catch (error) {
1793
+ throw new Error(`neutral file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1794
+ }
1795
+ if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error("neutral file must be a JSON object with a \"variables\" array.");
1796
+ const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
1797
+ const provider = typeof parsed["provider"] === "string" ? parsed["provider"] : void 0;
1798
+ return {
1799
+ schemaVersion,
1800
+ ...provider ? { provider } : {},
1801
+ variables: parsed["variables"].flatMap(toVariable)
1802
+ };
1803
+ };
1804
+ const toVariable = (raw) => {
1805
+ if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
1806
+ const type = typeof raw["type"] === "string" ? raw["type"] : "";
1807
+ const variable = {
1808
+ name: raw["name"],
1809
+ type
1810
+ };
1811
+ if (typeof raw["ref"] === "string") variable.ref = raw["ref"];
1812
+ else if (typeof raw["value"] === "string") variable.value = raw["value"];
1813
+ const modes = readStringMap(raw["modes"]);
1814
+ if (modes) variable.modes = modes;
1815
+ return [variable];
1816
+ };
1817
+ const readStringMap = (raw) => {
1818
+ if (!isRecord(raw)) return void 0;
1819
+ const out = {};
1820
+ for (const [key, value] of Object.entries(raw)) if (key.trim().length > 0 && typeof value === "string") out[key] = value;
1821
+ return Object.keys(out).length > 0 ? out : void 0;
1822
+ };
1823
+ const readJsonObject = (raw) => {
1824
+ let parsed;
1825
+ try {
1826
+ parsed = JSON.parse(raw);
1827
+ } catch (error) {
1828
+ throw new Error(`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
1829
+ }
1830
+ if (!isRecord(parsed)) throw new Error(`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`);
1831
+ return parsed;
1832
+ };
1833
+ const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
1834
+ const writeNeutralFile = (file) => writeJsonFile(file);
1835
+ //#endregion
1836
+ //#region src/design-tokens/import-tokens.ts
1837
+ const runImport = async (inputs) => {
1838
+ const empty = {
1839
+ tokensFound: false,
1840
+ changes: [],
1841
+ applied: false,
1842
+ written: [],
1843
+ skipped: [],
1844
+ problems: []
1845
+ };
1846
+ if (!await pathExists(inputs.neutralPath)) return {
1847
+ ...empty,
1848
+ problems: [`neutral file not found: ${inputs.neutralPath}`]
1849
+ };
1850
+ let neutral;
1851
+ try {
1852
+ neutral = parseNeutralFile(await readFile(inputs.neutralPath, "utf8"));
1853
+ } catch (error) {
1854
+ return {
1855
+ ...empty,
1856
+ problems: [asMessage$1(error)]
1857
+ };
1858
+ }
1859
+ if (neutral.schemaVersion !== 1) return {
1860
+ ...empty,
1861
+ problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
1862
+ };
1863
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1864
+ const tokensFound = await pathExists(tokenPath);
1865
+ let parsed;
1866
+ try {
1867
+ parsed = tokensFound ? readJsonObject(await readFile(tokenPath, "utf8")) : {};
1868
+ } catch (error) {
1869
+ return {
1870
+ ...empty,
1871
+ tokensFound,
1872
+ problems: [asMessage$1(error)]
1873
+ };
1874
+ }
1875
+ const changes = diffNeutralAgainstJson(neutral, parsed);
1876
+ if (!inputs.apply) return {
1877
+ tokensFound,
1878
+ changes,
1879
+ applied: false,
1880
+ written: [],
1881
+ skipped: [],
1882
+ problems: []
1883
+ };
1884
+ const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
1885
+ await mkdir(dirname(tokenPath), { recursive: true });
1886
+ await writeFile(tokenPath, writeJsonFile(merged));
1887
+ return {
1888
+ tokensFound,
1889
+ changes,
1890
+ applied: true,
1891
+ written,
1892
+ skipped,
1893
+ problems: []
1894
+ };
1895
+ };
1896
+ const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
1897
+ //#endregion
1898
+ //#region src/design-tokens/drift.ts
1899
+ const readBinding = (parsed) => {
1900
+ const extensions = parsed["$extensions"];
1901
+ const binding = isRecord(extensions) ? extensions[DESIGN_TOOL_EXTENSION] : void 0;
1902
+ if (!isRecord(binding)) return void 0;
1903
+ const provider = binding["provider"];
1904
+ if (typeof provider !== "string" || provider.trim().length === 0) return;
1905
+ const lastProjected = binding["lastProjected"];
1906
+ return {
1907
+ provider,
1908
+ ...typeof lastProjected === "string" ? { lastProjected } : {}
1909
+ };
1910
+ };
1911
+ const projectionHash = (parsed) => {
1912
+ const canonical = JSON.stringify(dtcgToNeutral(parsed).variables.map(canonicalVariable));
1913
+ return createHash("sha256").update(canonical).digest("hex");
1914
+ };
1915
+ const canonicalVariable = (variable) => ({
1916
+ name: variable.name,
1917
+ type: variable.type,
1918
+ value: variable.value ?? null,
1919
+ ref: variable.ref ?? null,
1920
+ modes: variable.modes ? Object.fromEntries(Object.keys(variable.modes).toSorted().map((name) => [name, variable.modes?.[name] ?? ""])) : null
1921
+ });
1922
+ const driftReport = (parsed) => {
1923
+ const binding = readBinding(parsed);
1924
+ if (!binding) return {
1925
+ tokensFound: true,
1926
+ provider: void 0,
1927
+ state: "n/a"
1928
+ };
1929
+ if (binding.lastProjected === void 0) return {
1930
+ tokensFound: true,
1931
+ provider: binding.provider,
1932
+ state: "never-projected"
1933
+ };
1934
+ const state = projectionHash(parsed) === binding.lastProjected ? "in-sync" : "export-pending";
1935
+ return {
1936
+ tokensFound: true,
1937
+ provider: binding.provider,
1938
+ state
1939
+ };
1940
+ };
1941
+ //#endregion
1942
+ //#region src/design-tokens/export-tokens.ts
1943
+ const runExport = async (inputs) => {
1944
+ const empty = {
1945
+ tokensFound: false,
1946
+ plan: [],
1947
+ hash: "",
1948
+ recorded: false,
1949
+ problems: []
1950
+ };
1951
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1952
+ if (!await pathExists(tokenPath)) return empty;
1953
+ let parsed;
1954
+ try {
1955
+ parsed = readJsonObject(await readFile(tokenPath, "utf8"));
1956
+ } catch (error) {
1957
+ return {
1958
+ ...empty,
1959
+ tokensFound: true,
1960
+ problems: [asMessage(error)]
1961
+ };
1962
+ }
1963
+ const binding = readBinding(parsed);
1964
+ if (!binding) return {
1965
+ ...empty,
1966
+ tokensFound: true,
1967
+ problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${DESIGN_TOKENS_FILE} before exporting.`]
1968
+ };
1969
+ const hash = projectionHash(parsed);
1970
+ let toolState;
1971
+ if (inputs.toolStatePath !== void 0) {
1972
+ if (!await pathExists(inputs.toolStatePath)) return {
1973
+ ...empty,
1974
+ tokensFound: true,
1975
+ hash,
1976
+ problems: [`tool-state file not found: ${inputs.toolStatePath}`]
1977
+ };
1978
+ try {
1979
+ toolState = parseNeutralFile(await readFile(inputs.toolStatePath, "utf8"));
1980
+ } catch (error) {
1981
+ return {
1982
+ ...empty,
1983
+ tokensFound: true,
1984
+ hash,
1985
+ problems: [asMessage(error)]
1986
+ };
1987
+ }
1988
+ }
1989
+ const plan = upsertPlan(parsed, toolState);
1990
+ if (inputs.outPath !== void 0) {
1991
+ const projection = dtcgToNeutral(parsed);
1992
+ projection.provider = binding.provider;
1993
+ await writeFile(inputs.outPath, writeNeutralFile(projection));
1994
+ }
1995
+ if (inputs.record) {
1996
+ await writeFile(tokenPath, writeJsonFile(stampBaseline(parsed, hash)));
1997
+ return {
1998
+ tokensFound: true,
1999
+ plan,
2000
+ hash,
2001
+ recorded: true,
2002
+ problems: []
2003
+ };
2004
+ }
2005
+ return {
2006
+ tokensFound: true,
2007
+ plan,
2008
+ hash,
2009
+ recorded: false,
2010
+ problems: []
2011
+ };
2012
+ };
2013
+ const stampBaseline = (parsed, hash) => {
2014
+ const clone = structuredClone(parsed);
2015
+ const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
2016
+ const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
2017
+ binding["lastProjected"] = hash;
2018
+ extensions[DESIGN_TOOL_EXTENSION] = binding;
2019
+ clone["$extensions"] = extensions;
2020
+ return clone;
2021
+ };
2022
+ const asMessage = (error) => error instanceof Error ? error.message : String(error);
2023
+ //#endregion
1301
2024
  //#region src/spinoff/spinoff.constant.ts
1302
- /**
1303
- * The presence labels a captured stub carries. They mirror `labels.model.ts`'s
1304
- * taxonomy (a guard test asserts the alignment, the same way the discovery verb guards
1305
- * its label strings).
1306
- */
1307
- /** Every captured stub is a harness-managed task. */
1308
2025
  const MANAGED_LABEL = "harness:managed";
1309
- /** It enters at `pending` — the fit level (L1/L2/L3) is decided at pickup, not capture. */
1310
2026
  const PENDING_STATUS_LABEL = "harness:status:pending";
1311
- /**
1312
- * The kind-routing flag a `--kind=architecture-drift` stub also carries (#148). Its
1313
- * presence tells a later pickup to resolve the stub via the Architect's
1314
- * `update-architecture` (ADR 0011), not a normal code task.
1315
- */
1316
2027
  const ARCHITECTURE_DRIFT_LABEL = "harness:architecture-drift";
1317
2028
  //#endregion
1318
2029
  //#region src/spinoff/spinoff.model.ts
1319
- /**
1320
- * The `--kind` values `spinoff` accepts (#148). A `kind` tags the stub with a routing
1321
- * label so a later pickup resolves it via the right path — today the only value,
1322
- * `architecture-drift`, routes to the Architect's `update-architecture` (ADR 0011)
1323
- * instead of a normal code task. A coupled `const … as const` + its derived type, so it
1324
- * stays in `.model.ts` per the code-conventions exception.
1325
- */
1326
2030
  const SPINOFF_KINDS = ["architecture-drift"];
1327
2031
  //#endregion
1328
2032
  //#region src/spinoff/spinoff.ts
1329
- /**
1330
- * Park a non-blocking, independent defect discovered mid-task as a tracked stub (#112,
1331
- * `/spinoff`): open a `harness:managed` + `harness:status:pending` issue (no
1332
- * fit-assessment, no spec — the level is decided at pickup), then emit a
1333
- * `followup_captured` telemetry line linking the stub to its parent task.
1334
- *
1335
- * Two tiers of strictness (mirrors the discovery verb, ADR 0007): the **stub creation
1336
- * is fail-loud** — it is the load-bearing artifact, so a failed create returns
1337
- * `ok:false` (CLI exits 1) and writes no telemetry. The **emit is best-effort** —
1338
- * once the stub exists the capture has succeeded, so a failed emit (or an issue number
1339
- * we can't parse from gh's output) leaves `ok:true` with a surfaced `warning`, never a
1340
- * silent skip and never a misleading failure.
1341
- *
1342
- * Unlike the discovery verb (which mutates an *existing* issue with idempotent label
1343
- * ops), capture is **not idempotent by design**: each run opens a fresh issue, so a
1344
- * repeat `/spinoff` for the same defect creates a second stub. Accepted for Phase 1 —
1345
- * a stub is cheap and dedup would add friction to the capture-and-keep-going path the
1346
- * PRD optimizes for; the human-repeat case is mitigated separately by the proactive
1347
- * offer's "no re-offer the same finding twice in a session" rule. The best-effort emit
1348
- * surfaces "stub #N created" precisely so a retry-on-warning re-creates nothing.
1349
- */
1350
2033
  const runSpinoff = async (inputs) => {
1351
2034
  const title = requireFlag(inputs.title, "title");
1352
2035
  const severity = parseSeverity(inputs.severity);
@@ -1410,15 +2093,9 @@ const runSpinoff = async (inputs) => {
1410
2093
  labelSync
1411
2094
  };
1412
2095
  };
1413
- /**
1414
- * Compose the stub body: the captor's detail plus a parent text-reference (PRD minor
1415
- * points — a cheap "Discovered during #N", no new label taxonomy). Either part may be
1416
- * absent; outside a task there is no parent line.
1417
- */
1418
2096
  const composeBody = (body, parentId) => {
1419
2097
  return [body?.trim(), parentId !== null ? `Discovered during #${parentId}` : ""].filter(Boolean).join("\n\n");
1420
2098
  };
1421
- /** Parse the trailing issue number from a `gh issue create` URL (…/issues/<n>). */
1422
2099
  const parseIssueId = (url) => url.match(/\/issues\/(\d+)\b/)?.[1];
1423
2100
  const requireFlag = (value, name) => {
1424
2101
  const trimmed = value?.trim();
@@ -1435,10 +2112,6 @@ const parseKind = (raw) => {
1435
2112
  if (SPINOFF_KINDS.includes(raw)) return raw;
1436
2113
  throw new Error(`spinoff: --kind must be one of ${SPINOFF_KINDS.join("|")} (got "${raw}").`);
1437
2114
  };
1438
- /**
1439
- * The presence labels a stub of this kind carries: the two every stub gets, plus the
1440
- * kind's routing label (#148) so a later pickup resolves it via the right path.
1441
- */
1442
2115
  const kindLabels = (kind) => {
1443
2116
  const labels = [MANAGED_LABEL, PENDING_STATUS_LABEL];
1444
2117
  if (kind === "architecture-drift") labels.push(ARCHITECTURE_DRIFT_LABEL);
@@ -1451,18 +2124,6 @@ const failureReason = (result) => {
1451
2124
  };
1452
2125
  //#endregion
1453
2126
  //#region src/telemetry/sanitize.constant.ts
1454
- /**
1455
- * The sanitizer axis of every `(event_type, field)` occurrence — the executable
1456
- * mirror of the axis tables in `catalog/schemas/tier2-events.md` (the authority;
1457
- * the doc wins on conflict). `sanitize-doc-sync.spec.ts` parses the doc and asserts
1458
- * it equals this map; `sanitize.constant.spec.ts` asserts this map's key set is
1459
- * **exactly** the Zod schema's field set per type (no field untagged, none extra) —
1460
- * the fail-closed completeness gate (D9).
1461
- *
1462
- * Every type carries the six envelope fields (spread from `ENVELOPE_AXIS`) plus its
1463
- * own. `task_id` rides `identity` everywhere: a per-project correlator, meaningless
1464
- * once `project` is dropped.
1465
- */
1466
2127
  const ENVELOPE_AXIS = {
1467
2128
  type: "internal-enum",
1468
2129
  ts: "metric",
@@ -1528,13 +2189,6 @@ const FIELD_AXIS = {
1528
2189
  attributed_name: "internal-enum"
1529
2190
  }
1530
2191
  };
1531
- /**
1532
- * Which axes survive each export tier (D9). `local-only` is in neither set — it
1533
- * never leaves the laptop. `anonymous` is the strict subset; `project` additionally
1534
- * keeps `identity` + `free-text`. A field is kept iff its axis is in the tier's set;
1535
- * any field whose axis is absent — or that has no axis entry at all — is dropped
1536
- * (fail-closed).
1537
- */
1538
2192
  const KEEP_BY_TIER = {
1539
2193
  anonymous: /* @__PURE__ */ new Set(["internal-enum", "metric"]),
1540
2194
  project: /* @__PURE__ */ new Set([
@@ -1546,17 +2200,6 @@ const KEEP_BY_TIER = {
1546
2200
  };
1547
2201
  //#endregion
1548
2202
  //#region src/telemetry/sanitize.ts
1549
- /**
1550
- * Project one raw `events.jsonl` line to the fields the tier keeps, or `null` to
1551
- * drop the whole line. Fail-closed on every front (D9): malformed JSON, an unknown
1552
- * `type`, an invalid shape, or an unexpected key (Zod `.strict()`) all drop the
1553
- * line; a field whose axis is absent from the tier's keep-set — or that has no axis
1554
- * entry at all — is dropped. Only `internal-enum` + `metric` survive `anonymous`,
1555
- * so a valid event always keeps at least `{ type, ts, harness_version }`.
1556
- *
1557
- * Key order is preserved from the parsed line, so re-serializing the same bytes
1558
- * yields the same output and the same content-hash (D6 idempotency).
1559
- */
1560
2203
  const sanitizeLine = (raw, tier) => {
1561
2204
  let parsed;
1562
2205
  try {
@@ -1578,67 +2221,16 @@ const sanitizeLine = (raw, tier) => {
1578
2221
  };
1579
2222
  //#endregion
1580
2223
  //#region src/telemetry/telemetry.constant.ts
1581
- /** The append-only event log the cursor walks (ADR 0008). */
1582
2224
  const EVENTS_RELPATH = join(".claude", "state", "events.jsonl");
1583
- /** Byte-offset watermark of what has been delivered (D5, gitignored). */
1584
2225
  const CURSOR_RELPATH = join(".claude", "state", "telemetry-cursor.json");
1585
- /**
1586
- * The quarantine log (#227, D17): lines the client decided **not** to send —
1587
- * Zod-invalid (`invalid-schema`) or a single line over the payload cap (`oversize`).
1588
- * Gitignored + inspectable; "nothing is silently lost". One `{ts, reason, line}` JSON
1589
- * object per quarantined line (append-only). NOT the wire — these never leave the box.
1590
- */
1591
2226
  const REJECTED_RELPATH = join(".claude", "state", "telemetry-rejected.jsonl");
1592
- /**
1593
- * Client-side payload cap (~1MB, D16). The send engine chunks the unsent tail into
1594
- * line-aligned segments whose **sanitized** body stays ≤ this, so a 413 from the
1595
- * Worker (its own ~1MB backstop, #230) can never permanently stall a cursor. Normal
1596
- * batches are KB — this only bites a large uncleanly-closed backlog or a pathological
1597
- * single line. The Worker cap must be ≥ this value so a maxed-out segment is accepted.
1598
- */
1599
2227
  const PAYLOAD_CAP_BYTES = 1e6;
1600
- /** Per-request hard timeout — the send must never stall a hook (D4). */
1601
2228
  const DEFAULT_TIMEOUT_MS = 3e3;
1602
- /**
1603
- * Prefix-prune threshold (#240, amends ADR 0008's append-only invariant). The send
1604
- * engine collapses the **confirmed-sent prefix** of `events.jsonl` only once that
1605
- * prefix (the cursor offset `C`) has grown past this — so a small log is never
1606
- * rewritten, and the bounded growth is reclaimed in coarse ~5 MB steps (~25-50k tiny
1607
- * events). Only the 2xx-delivered prefix is ever dropped; unsent bytes are preserved.
1608
- */
1609
2229
  const PRUNE_THRESHOLD_BYTES = 5e6;
1610
- /**
1611
- * Basename of the per-prune drain file: `events.draining.<id>` beside `events.jsonl`,
1612
- * paired with a `.meta` sidecar holding the sent offset `C`. A unique `<id>` per prune
1613
- * (#240) stops two concurrent send processes from clobbering each other's drain via
1614
- * `rename`. Recovery globs `events.draining.*` and replays any orphan left by a crash.
1615
- */
1616
2230
  const DRAINING_BASENAME = "events.draining";
1617
- /**
1618
- * Base URL of the deployed ingest Worker (`telemetry-worker/`, #225). The
1619
- * `LEMONY_TELEMETRY_ENDPOINT` env var overrides it (e.g. the /tmp e2e smoke or
1620
- * a local `wrangler dev`). An empty value → send is disabled.
1621
- */
1622
2231
  const TELEMETRY_ENDPOINT = "https://lemony-telemetry.lemoncode.workers.dev";
1623
2232
  //#endregion
1624
2233
  //#region src/telemetry/chunk-tail.ts
1625
- /**
1626
- * Split the unsent raw tail of `events.jsonl` into line-aligned segments whose
1627
- * **sanitized** body stays ≤ `capBytes` (#227, D16) — so a 413 can never permanently
1628
- * stall the cursor (D5). Each segment carries the exact span of **raw** bytes it
1629
- * consumes (kept + rejected + blank lines, newlines included), so the caller advances
1630
- * the cursor per-segment without re-deriving offsets; the raw spans sum to the tail's
1631
- * byte length exactly.
1632
- *
1633
- * Per line (D17): `sanitizeLine` returning `null` ⟺ the same Zod that gates the wire
1634
- * rejected it → an `invalid-schema` reject; a deliverable line whose sanitized form
1635
- * alone exceeds the cap → an `oversize` reject (pathological; free text is bounded).
1636
- * Rejects ride inside the segment that contains them and flush to quarantine only when
1637
- * that segment advances (atomic with the cursor). A segment carrying only rejects/blanks
1638
- * (no deliverable body) has `body: null` and can appear **only** as the trailing segment.
1639
- *
1640
- * Pure and deterministic: no clock, no IO. The flush timestamp is stamped by the caller.
1641
- */
1642
2234
  const chunkTail = (rawTail, tier, capBytes = PAYLOAD_CAP_BYTES) => {
1643
2235
  const parts = rawTail.split("\n");
1644
2236
  const segments = [];
@@ -1693,20 +2285,7 @@ const chunkTail = (rawTail, tier, capBytes = PAYLOAD_CAP_BYTES) => {
1693
2285
  };
1694
2286
  //#endregion
1695
2287
  //#region src/telemetry/consent.constant.ts
1696
- /**
1697
- * Per-repo-local personal opt-out (#229): a developer's "off for *this* checkout"
1698
- * choice, gitignored beside its `state/` siblings so it is never committed (a
1699
- * committed opt-out would silence the whole team — that is the committed
1700
- * `telemetry.enabled: false` lever instead). Read-only here; the `disable`/`enable`
1701
- * writer is the verb set (#232). Shape: `{ "disabled": true }`.
1702
- */
1703
2288
  const CONSENT_RELPATH = join(".claude", "state", "telemetry-consent.json");
1704
- /**
1705
- * Truthy allow-list for `LEMONY_TELEMETRY_DISABLED` (case-insensitive, trimmed):
1706
- * only these values disable. `0`/`false`/empty/unset have no effect — the env is an
1707
- * off-switch, never an on-switch (the on path is the on-by-default floor). Next.js
1708
- * style (`=1`).
1709
- */
1710
2289
  const ENV_DISABLE_VALUES = [
1711
2290
  "1",
1712
2291
  "true",
@@ -1714,23 +2293,12 @@ const ENV_DISABLE_VALUES = [
1714
2293
  ];
1715
2294
  //#endregion
1716
2295
  //#region src/telemetry/consent.ts
1717
- /** The env var disables only on its truthy allow-list; everything else is inert. */
1718
2296
  const isEnvDisabled = (value) => value !== void 0 && ENV_DISABLE_VALUES.includes(value.trim().toLowerCase());
1719
- /**
1720
- * The cross-tool `DO_NOT_TRACK` standard: present and ≠ `0`/empty → opted out. Unlike
1721
- * the Lemony allow-list, ANY non-`0` value disables (privacy-conservative): a developer
1722
- * who exported it globally wants telemetry off by default, whatever the value (#237).
1723
- */
1724
2297
  const isDoNotTrackSet = (value) => {
1725
2298
  if (value === void 0) return false;
1726
2299
  const trimmed = value.trim();
1727
2300
  return trimmed !== "" && trimmed !== "0";
1728
2301
  };
1729
- /**
1730
- * Read the per-repo-local opt-out. A missing file, unreadable dir, malformed JSON,
1731
- * or anything other than a literal `disabled: true` → not opted out (tolerant, like
1732
- * the cursor reader): the lever is one-directional, so only `true` silences.
1733
- */
1734
2302
  const isLocallyOptedOut = async (repoRoot) => {
1735
2303
  try {
1736
2304
  const raw = await readFile(join(repoRoot, CONSENT_RELPATH), "utf8");
@@ -1739,22 +2307,6 @@ const isLocallyOptedOut = async (repoRoot) => {
1739
2307
  return false;
1740
2308
  }
1741
2309
  };
1742
- /**
1743
- * Resolve whether a telemetry send may proceed (#229). Five **off-switches over an
1744
- * on-by-default floor**, highest precedence first (first OFF wins, short-circuit):
1745
- *
1746
- * 1. env `DO_NOT_TRACK` (cross-tool standard, any non-`0` value) — `source: do-not-track`
1747
- * 2. env `LEMONY_TELEMETRY_DISABLED` (truthy) — `source: env`
1748
- * 3. per-repo-local `.claude/state/telemetry-consent.json` `disabled:true` — `local`
1749
- * 4. committed `harness.config.yml` `telemetry.enabled: false` — `config`
1750
- * 5. nothing set → ON, tier `anonymous` — `source: default`
1751
- *
1752
- * A committed `enabled: true` is a no-op affirmation of the floor, so the ON source
1753
- * is always `default`. **Fail-safe**: if the config is missing or invalid (it throws
1754
- * under `.strict()`), resolve OFF with `source: config` — privacy-conservative, so a
1755
- * broken config never silently sends; consistent with telemetry's silent-fail (D4).
1756
- * The send-engine consults this first and no-ops when OFF (defense-in-depth).
1757
- */
1758
2310
  const resolveConsent = async ({ repoRoot, env: env$4 = env }) => {
1759
2311
  if (isDoNotTrackSet(env$4["DO_NOT_TRACK"])) return {
1760
2312
  enabled: false,
@@ -1786,20 +2338,9 @@ const resolveConsent = async ({ repoRoot, env: env$4 = env }) => {
1786
2338
  };
1787
2339
  //#endregion
1788
2340
  //#region src/telemetry/content-hash.ts
1789
- /**
1790
- * SHA-256 of the batch bytes as lowercase hex — the R2 object key stem (D6).
1791
- * A lost-ack re-send of the same bytes produces the same key and overwrites,
1792
- * never duplicates. Per-content, NOT a persistent install id (safe for
1793
- * anonymous).
1794
- */
1795
2341
  const contentHash = (bytes) => createHash("sha256").update(bytes).digest("hex");
1796
2342
  //#endregion
1797
2343
  //#region src/telemetry/cursor.ts
1798
- /**
1799
- * Read the byte-offset cursor. A missing, empty, or unparseable cursor is
1800
- * treated as "never sent" (`offset: 0`) — telemetry must never throw into a
1801
- * hook, and resending from the start is safe (idempotent content-hash keys).
1802
- */
1803
2344
  const readCursor = async (repoRoot) => {
1804
2345
  try {
1805
2346
  const raw = await readFile(join(repoRoot, CURSOR_RELPATH), "utf8");
@@ -1815,7 +2356,6 @@ const readCursor = async (repoRoot) => {
1815
2356
  };
1816
2357
  }
1817
2358
  };
1818
- /** Persist the cursor (pretty-printed JSON), creating parent dirs as needed. */
1819
2359
  const writeCursor = async (repoRoot, cursor) => {
1820
2360
  const path = join(repoRoot, CURSOR_RELPATH);
1821
2361
  await mkdir(dirname(path), { recursive: true });
@@ -1825,30 +2365,12 @@ const writeCursor = async (repoRoot, cursor) => {
1825
2365
  //#region src/telemetry/prune.ts
1826
2366
  const stateDirOf = (repoRoot) => join(repoRoot, dirname(EVENTS_RELPATH));
1827
2367
  const eventsPathOf = (repoRoot) => join(repoRoot, EVENTS_RELPATH);
1828
- /** A drain file is `events.draining.<id>` (its `.meta` sidecar is excluded). */
1829
2368
  const isDrainFile = (name) => name.startsWith(`events.draining.`) && !name.endsWith(".meta");
1830
2369
  const isDrainMeta = (name) => name.startsWith(`events.draining.`) && name.endsWith(".meta");
1831
- /**
1832
- * The unsent boundary in a drain buffer: the sidecar's `sent_offset` when it is a
1833
- * trustworthy line boundary (`C === 0` or the byte before it is `\n`) within bounds,
1834
- * else 0 — meaning "replay everything" (a missing/corrupt sidecar or an off-boundary
1835
- * offset must never let us drop bytes that may be unsent). Mirrors the send engine's
1836
- * cursor-robustness guard.
1837
- */
1838
2370
  const trustedOffset = (buf, sentOffset) => {
1839
2371
  if (!Number.isFinite(sentOffset) || sentOffset <= 0 || sentOffset > buf.length) return 0;
1840
2372
  return buf[sentOffset - 1] === 10 ? sentOffset : 0;
1841
2373
  };
1842
- /**
1843
- * Append the unsent overflow (whole lines) back into the fresh `events.jsonl`, batching
1844
- * up to `ATOMIC_APPEND_BYTES` per write so it can never tear against a concurrent
1845
- * `appendEvent` (#240). Each batch stays ≤ the cap because **every event line is well under
1846
- * `PIPE_BUF` by schema** (`catalog/schemas/tier2-events.md`) — the same invariant the
1847
- * primary emitter (`src/events/append.ts`) relies on for its own `O_APPEND` atomicity; a
1848
- * line over the cap can't exist without already breaking that emitter. Blank lines carry no
1849
- * event and are dropped. A single `O_APPEND` handle is reused for the whole replay; `'a'`
1850
- * re-creates the file if an appender has not yet recreated it.
1851
- */
1852
2374
  const replayOverflow = async (eventsPath, overflow) => {
1853
2375
  const handle = await open(eventsPath, "a");
1854
2376
  try {
@@ -1867,7 +2389,6 @@ const replayOverflow = async (eventsPath, overflow) => {
1867
2389
  await handle.close();
1868
2390
  }
1869
2391
  };
1870
- /** Replay one drain file's unsent tail into `events.jsonl`, then delete it + its meta. */
1871
2392
  const drainOne = async (stateDir, drainName) => {
1872
2393
  const drainPath = join(stateDir, drainName);
1873
2394
  const metaPath = `${drainPath}.meta`;
@@ -1892,13 +2413,6 @@ const drainOne = async (stateDir, drainName) => {
1892
2413
  await rm(metaPath, { force: true });
1893
2414
  return buf.byteLength - from;
1894
2415
  };
1895
- /**
1896
- * Recovery preamble (#240): replay any orphaned `events.draining.*` left by a crashed
1897
- * prune back into `events.jsonl` before the send reads the tail, so a half-finished
1898
- * prune never loses unsent events. Idempotent — re-running re-appends already-replayed
1899
- * lines as harmless byte-identical duplicates (same content-hash R2 key). Never throws
1900
- * (it runs in the fire-and-forget send, D4).
1901
- */
1902
2416
  const recoverDrainings = async (repoRoot) => {
1903
2417
  const stateDir = stateDirOf(repoRoot);
1904
2418
  let entries;
@@ -1911,23 +2425,6 @@ const recoverDrainings = async (repoRoot) => {
1911
2425
  await Promise.all(entries.filter(isDrainMeta).filter((meta) => !entries.includes(meta.slice(0, -5))).map((meta) => rm(join(stateDir, meta), { force: true }).catch(() => {})));
1912
2426
  return { recovered };
1913
2427
  };
1914
- /**
1915
- * Collapse the confirmed-sent prefix of `events.jsonl` once it exceeds the threshold
1916
- * (#240), reclaiming bounded local growth without ever dropping unsent bytes. Lock-free
1917
- * and crash-safe via rename + replay-overflow:
1918
- *
1919
- * 1. read `C = cursor.offset` (the 2xx-confirmed watermark); below the threshold → no-op.
1920
- * 2. write the `.meta` sidecar (`{ sent_offset: C }`), then `rename` the live file to a
1921
- * unique `events.draining.<id>` — atomic; concurrent emitters `O_CREAT` a fresh log.
1922
- * 3. reset the cursor to 0 (it now describes the fresh log; a crash before this leaves a
1923
- * stale-large cursor the send's past-EOF guard resends from 0).
1924
- * 4. replay the drain's unsent tail (`[C:]`) line-by-line into the fresh log, then delete
1925
- * the drain + meta.
1926
- *
1927
- * Relies on events being order-independent (aggregation groups by version/component; the
1928
- * cursor counts bytes, not order), so interleaving replay with live appends is safe.
1929
- * Never throws (D4).
1930
- */
1931
2428
  const prunePrefix = async (repoRoot, options = {}) => {
1932
2429
  const { thresholdBytes = PRUNE_THRESHOLD_BYTES } = options;
1933
2430
  try {
@@ -1967,7 +2464,6 @@ const prunePrefix = async (repoRoot, options = {}) => {
1967
2464
  return { prunedBytes: 0 };
1968
2465
  }
1969
2466
  };
1970
- /** Remove every `events.draining.*` (drain + meta) — used by `disable --purge-local`. */
1971
2467
  const purgeDrainings = async (repoRoot) => {
1972
2468
  const stateDir = stateDirOf(repoRoot);
1973
2469
  let entries;
@@ -1980,16 +2476,6 @@ const purgeDrainings = async (repoRoot) => {
1980
2476
  };
1981
2477
  //#endregion
1982
2478
  //#region src/telemetry/quarantine.ts
1983
- /**
1984
- * Append rejected lines to the quarantine log (#227, D17) — one `{ts, reason, line}`
1985
- * JSON object per line, so "nothing is silently lost": a clock-skewed or otherwise
1986
- * invalid line, or a pathological over-cap one, lands here (gitignored, inspectable)
1987
- * instead of on the wire. Called by the send engine **only when the owning segment
1988
- * advances the cursor**, so a transient `5xx`/offline retry never double-quarantines.
1989
- *
1990
- * Stamps each entry with the injected `now` (the same clock as the cursor) and returns
1991
- * how many were written (0 → no file touched). Append-only; parent dirs created lazily.
1992
- */
1993
2479
  const appendRejects = async (repoRoot, rejects, now) => {
1994
2480
  if (rejects.length === 0) return 0;
1995
2481
  const path = join(repoRoot, REJECTED_RELPATH);
@@ -2007,7 +2493,6 @@ const appendRejects = async (repoRoot, rejects, now) => {
2007
2493
  };
2008
2494
  //#endregion
2009
2495
  //#region src/telemetry/send-telemetry.ts
2010
- /** A result with the wire counters zeroed — the no-op / early-return baseline. */
2011
2496
  const emptyResult = (outcome, extra = {}) => ({
2012
2497
  outcome,
2013
2498
  segmentsSent: 0,
@@ -2017,35 +2502,6 @@ const emptyResult = (outcome, extra = {}) => ({
2017
2502
  keys: [],
2018
2503
  ...extra
2019
2504
  });
2020
- /**
2021
- * Send the unsent tail of `events.jsonl`, robustly (#227). The tail is **sanitized to
2022
- * the `anonymous` tier** (Phase 2 / #226 — the no-leak gate sits before the wire) and
2023
- * **chunked** into line-aligned segments whose sanitized body stays ≤ the payload cap
2024
- * (D16): each is its own content-hash R2 object (`anonymous/<sha256>.jsonl`), and the
2025
- * byte-offset cursor advances **per-segment** so no backlog size or pathological line
2026
- * can permanently stall it. Lines the client won't send (Zod-invalid, or a single
2027
- * over-cap line) are **quarantined** to `telemetry-rejected.jsonl` (D17), never sent.
2028
- *
2029
- * Cursor policy (D17, revising D5's bare "advance only on 2xx"):
2030
- * - `2xx` → advance past the segment + flush its quarantined lines.
2031
- * - `4xx` (should-never-happen post-pre-validate) → loud local log + advance past the
2032
- * chunk (don't retry forever).
2033
- * - `5xx` / network / timeout → transient: stop, do **not** advance; the next hook
2034
- * retries the same bytes (preserves the offline invariant, D4).
2035
- *
2036
- * The cursor is also hardened against truncation/desync: a cursor past EOF, or one no
2037
- * longer on a line boundary (events.jsonl edited under us), resends from 0 — idempotent
2038
- * content-hash keys make a re-PUT of already-stored bytes harmless.
2039
- *
2040
- * After delivering, the confirmed-sent prefix of `events.jsonl` is **pruned** once it
2041
- * exceeds `PRUNE_THRESHOLD_BYTES` (#240, amends ADR 0008's append-only invariant) — a
2042
- * crash-safe, lock-free rename + replay-overflow that reclaims bounded local growth
2043
- * without ever dropping unsent bytes. A `recoverDrainings` preamble first replays any
2044
- * drain a previous crashed prune left behind, so its unsent tail joins this run's send.
2045
- *
2046
- * **Never throws.** Every failure path returns a result without losing data, so a hook
2047
- * is never blocked or broken (D4).
2048
- */
2049
2505
  const sendTelemetry = async (options) => {
2050
2506
  const { repoRoot, endpoint = TELEMETRY_ENDPOINT, env, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => /* @__PURE__ */ new Date(), capBytes = PAYLOAD_CAP_BYTES, pruneThresholdBytes } = options;
2051
2507
  const consent = await resolveConsent({
@@ -2146,37 +2602,14 @@ const sendTelemetry = async (options) => {
2146
2602
  };
2147
2603
  //#endregion
2148
2604
  //#region src/telemetry/consent-writer.ts
2149
- /**
2150
- * The writer counterpart to `consent.ts` (which only reads, #229). The verb set
2151
- * (#232) owns the lever: `disable` writes the per-repo-local opt-out, `enable`
2152
- * clears it. Both touch only `.claude/state/telemetry-consent.json` — never the
2153
- * committed `harness.config.yml` (a team decision the dev must not flip silently)
2154
- * nor the env var (a shell-/machine-wide lever outside the repo).
2155
- */
2156
- /**
2157
- * Opt this checkout out: write `{ "disabled": true }` (the only value `consent.ts`
2158
- * honors). Creates `.claude/state/` if missing so `disable` works before the first
2159
- * session has written any state.
2160
- */
2161
2605
  const disableLocally = async (repoRoot) => {
2162
2606
  const path = join(repoRoot, CONSENT_RELPATH);
2163
2607
  await mkdir(dirname(path), { recursive: true });
2164
2608
  await writeFile(path, `${JSON.stringify({ disabled: true }, null, 2)}\n`);
2165
2609
  };
2166
- /**
2167
- * Clear the local opt-out by **deleting** the file — there is no "force on", only a
2168
- * return to the on-by-default floor, so a lingering `{ disabled: false }` artifact
2169
- * would be misleading. `force: true` makes a no-op enable (never disabled) safe.
2170
- */
2171
2610
  const enableLocally = async (repoRoot) => {
2172
2611
  await rm(join(repoRoot, CONSENT_RELPATH), { force: true });
2173
2612
  };
2174
- /**
2175
- * `disable --purge-local`: delete the local event log, the send cursor, and any prune
2176
- * drains (#240). Remote deletion is impossible by design (anonymous events carry no
2177
- * identity to match on), so this only wipes what lives on this machine. Best-effort
2178
- * (`force`) — a missing file is success.
2179
- */
2180
2613
  const purgeLocal = async (repoRoot) => {
2181
2614
  await rm(join(repoRoot, EVENTS_RELPATH), { force: true });
2182
2615
  await rm(join(repoRoot, CURSOR_RELPATH), { force: true });
@@ -2184,13 +2617,6 @@ const purgeLocal = async (repoRoot) => {
2184
2617
  };
2185
2618
  //#endregion
2186
2619
  //#region src/telemetry/telemetry-command.constant.ts
2187
- /**
2188
- * Human-readable explanation of which layer decided consent — surfaced by `status`,
2189
- * `disable`, and `enable` so the user always sees *why* telemetry is on or off (the
2190
- * precedence-honesty requirement, #232 C). `config` folds in the fail-safe case: an
2191
- * invalid `harness.config.yml` resolves OFF with `source: config` (#229), so the label
2192
- * names both the explicit `enabled: false` and the broken-config path.
2193
- */
2194
2620
  const SOURCE_LABEL = {
2195
2621
  default: "default (on)",
2196
2622
  "do-not-track": "the DO_NOT_TRACK environment variable (cross-tool standard)",
@@ -2200,13 +2626,6 @@ const SOURCE_LABEL = {
2200
2626
  };
2201
2627
  //#endregion
2202
2628
  //#region src/telemetry/telemetry-status.ts
2203
- /**
2204
- * Build the `telemetry status` report: the **effective** consent decision (whatever
2205
- * the resolver returns, env › local › config › default) plus the send watermark. Pure
2206
- * read — resolves consent and the cursor, never writes. `anonymous` is the only tier
2207
- * in v1, so the report carries no tier field (a committed `tier:` is a `.strict()`
2208
- * error, #229); the formatter labels the ON state "anonymous" inline.
2209
- */
2210
2629
  const buildTelemetryStatus = async ({ repoRoot, env: env$3 = env }) => {
2211
2630
  const { enabled, source } = await resolveConsent({
2212
2631
  repoRoot,
@@ -2220,7 +2639,6 @@ const buildTelemetryStatus = async ({ repoRoot, env: env$3 = env }) => {
2220
2639
  lastSentTs: cursor.last_sent_ts
2221
2640
  };
2222
2641
  };
2223
- /** Render the status report as terse, human lines (one per `console.log`). */
2224
2642
  const formatTelemetryStatus = (report) => {
2225
2643
  const headline = report.enabled ? "Telemetry: ON (anonymous)" : "Telemetry: OFF";
2226
2644
  const lastSend = report.lastSentTs === "" ? "never" : report.lastSentTs;
@@ -2232,12 +2650,6 @@ const formatTelemetryStatus = (report) => {
2232
2650
  };
2233
2651
  //#endregion
2234
2652
  //#region src/telemetry/telemetry-show.ts
2235
- /**
2236
- * Build the `telemetry show` report — radical transparency (#232 F): every local event
2237
- * in `events.jsonl`, paired with its `anonymous` export projection (`null` when the
2238
- * sanitizer drops the line, e.g. a `local-only` field or an unknown event type). A
2239
- * missing/unreadable log reads as zero events. Read-only.
2240
- */
2241
2653
  const buildTelemetryShow = async (repoRoot) => {
2242
2654
  let raw;
2243
2655
  try {
@@ -2257,11 +2669,6 @@ const buildTelemetryShow = async (repoRoot) => {
2257
2669
  total: events.length
2258
2670
  };
2259
2671
  };
2260
- /**
2261
- * Render the show report: the full raw log, then the full sanitized projection, so the
2262
- * user can compare what is stored locally against exactly what would leave the machine.
2263
- * No truncation by default — the user asked for the dump; paging is the shell's job.
2264
- */
2265
2672
  const formatTelemetryShow = (report) => {
2266
2673
  if (report.total === 0) return ["No local telemetry events (.claude/state/events.jsonl is empty or absent)."];
2267
2674
  const lines = [`${report.total} local event(s).`, ""];
@@ -2274,23 +2681,10 @@ const formatTelemetryShow = (report) => {
2274
2681
  };
2275
2682
  //#endregion
2276
2683
  //#region src/telemetry/telemetry-notice.constant.ts
2277
- /**
2278
- * Sentinel that records the **acknowledged consent fingerprint** (`enabled:source`),
2279
- * not just "shown once" (D13): the disclosure re-shows whenever the effective consent
2280
- * changes (fingerprint mismatch). Gitignored beside its `state/` siblings — it is a
2281
- * per-checkout ack, never committed. A dotfile so it does not clutter `state/` listings.
2282
- */
2283
2684
  const NOTICE_SENTINEL_RELPATH = join(".claude", "state", ".telemetry-notice-shown");
2284
- /**
2285
- * The one-shot opt-out disclosure shown at `install` and on the first SessionStart
2286
- * under on-by-default (D11, R2-critical). Names the exact opt-out verb and points at
2287
- * PRIVACY.md (which lands in Phase 9, #233; the link text is wired now, the publish
2288
- * gates on the file existing).
2289
- */
2290
2685
  const NOTICE_MESSAGE = "Anonymous telemetry is ON — run `lemony telemetry disable` to opt out. See PRIVACY.md.";
2291
2686
  //#endregion
2292
2687
  //#region src/telemetry/telemetry-notice.ts
2293
- /** Read the acknowledged fingerprint; a missing/unreadable sentinel reads as `''`. */
2294
2688
  const readSentinel = async (repoRoot) => {
2295
2689
  try {
2296
2690
  return (await readFile(join(repoRoot, NOTICE_SENTINEL_RELPATH), "utf8")).trim();
@@ -2298,19 +2692,11 @@ const readSentinel = async (repoRoot) => {
2298
2692
  return "";
2299
2693
  }
2300
2694
  };
2301
- /** Persist the acknowledged fingerprint, creating `.claude/state/` if needed. */
2302
2695
  const writeSentinel = async (repoRoot, fingerprint) => {
2303
2696
  const path = join(repoRoot, NOTICE_SENTINEL_RELPATH);
2304
2697
  await mkdir(dirname(path), { recursive: true });
2305
2698
  await writeFile(path, `${fingerprint}\n`);
2306
2699
  };
2307
- /**
2308
- * Decide whether the opt-out disclosure should show (D11/D13). The fingerprint is the
2309
- * resolved `enabled:source`; the disclosure prints **only when telemetry is ON** and
2310
- * that fingerprint differs from the sentinel's last ack. When OFF, it never shows —
2311
- * the "telemetry is ON, here's how to opt out" copy is meaningless once it is off, and
2312
- * `telemetry status` covers the off case on demand. Pure read; `runNotice` does the I/O.
2313
- */
2314
2700
  const resolveNotice = async ({ repoRoot, env: env$1 = env }) => {
2315
2701
  const { enabled, source } = await resolveConsent({
2316
2702
  repoRoot,
@@ -2328,13 +2714,6 @@ const resolveNotice = async ({ repoRoot, env: env$1 = env }) => {
2328
2714
  message: NOTICE_MESSAGE
2329
2715
  };
2330
2716
  };
2331
- /**
2332
- * Show the disclosure if due, then record the ack so it stays quiet until the effective
2333
- * consent changes. `print` is injected (the CLI passes `console.log`; tests capture).
2334
- * Invoked from `install` (post-install printout) and from `init.sh` on SessionStart
2335
- * (shelled out as `lemony telemetry notice`, #232 G — single TS resolver, no bash
2336
- * duplication). Returns the decision so callers can assert/branch.
2337
- */
2338
2717
  const runNotice = async ({ repoRoot, env: env$2 = env, print }) => {
2339
2718
  const decision = await resolveNotice({
2340
2719
  repoRoot,
@@ -2348,13 +2727,6 @@ const runNotice = async ({ repoRoot, env: env$2 = env, print }) => {
2348
2727
  };
2349
2728
  //#endregion
2350
2729
  //#region src/telemetry/telemetry-flush.ts
2351
- /**
2352
- * Format the result of a `telemetry flush` (#227) as human-readable lines — the
2353
- * verbose, user-facing counterpart to the terse one-liner `telemetry send` prints for
2354
- * the hook. Same engine, richer presentation: it surfaces what the robust send engine
2355
- * did (segments delivered, bytes, quarantined lines, server-rejected chunks) so a human
2356
- * debugging "is my telemetry flowing?" gets the whole picture. Pure (no IO).
2357
- */
2358
2730
  const formatTelemetryFlush = (result) => {
2359
2731
  if (result.outcome === "disabled") return [`Telemetry is off${result.source ? ` (source: ${result.source})` : ""} — nothing was sent.`];
2360
2732
  const reclaimed = result.prunedBytes !== void 0 && result.prunedBytes > 0 ? ` reclaimed: ${result.prunedBytes} byte(s) of confirmed-sent events` : void 0;
@@ -2376,11 +2748,6 @@ const formatTelemetryFlush = (result) => {
2376
2748
  };
2377
2749
  //#endregion
2378
2750
  //#region src/colors/colors.constant.ts
2379
- /**
2380
- * Raw ANSI SGR escape sequences — the whole palette the CLI uses. Kept
2381
- * dependency-free on purpose (#175): four status colors plus dim cover every
2382
- * console report; a color library would be the heavier tool for the same job.
2383
- */
2384
2751
  const ANSI = {
2385
2752
  green: "\x1B[32m",
2386
2753
  yellow: "\x1B[33m",
@@ -2391,23 +2758,12 @@ const ANSI = {
2391
2758
  };
2392
2759
  //#endregion
2393
2760
  //#region src/colors/colors.ts
2394
- /**
2395
- * Color gate per https://no-color.org/: emit ANSI only on a TTY, and honor
2396
- * `NO_COLOR` when it is present **and non-empty** (an empty string does not
2397
- * disable, per the spec's wording). No `--no-color` flag and no `FORCE_COLOR`:
2398
- * the env var + TTY cover every reported need (#175, fork 3).
2399
- */
2400
2761
  const colorsEnabled = (options) => {
2401
2762
  const noColor = options.env["NO_COLOR"];
2402
2763
  return options.isTty && (noColor === void 0 || noColor === "");
2403
2764
  };
2404
2765
  const wrap = (code) => (text) => `${code}${text}${ANSI.reset}`;
2405
2766
  const identity = (text) => text;
2406
- /**
2407
- * Build the palette once per process from the gate's verdict. Callers never
2408
- * branch on color support — they always paint, and a disabled palette paints
2409
- * with the identity.
2410
- */
2411
2767
  const buildPalette = (enabled) => enabled ? {
2412
2768
  ok: wrap(ANSI.green),
2413
2769
  warn: wrap(ANSI.yellow),
@@ -2423,123 +2779,8 @@ const buildPalette = (enabled) => enabled ? {
2423
2779
  };
2424
2780
  //#endregion
2425
2781
  //#region src/install/devdependency.constant.ts
2426
- /**
2427
- * The harness package — the devDependency a Node target should carry so the
2428
- * telemetry CLI resolves from `node_modules/.bin` (#107/#113).
2429
- */
2430
2782
  const LEMONY_PACKAGE = "@lemoncode/lemony";
2431
2783
  //#endregion
2432
- //#region src/fs/exists.ts
2433
- /** True when a file or directory exists at `path`. */
2434
- const pathExists = async (path) => {
2435
- try {
2436
- await stat(path);
2437
- return true;
2438
- } catch {
2439
- return false;
2440
- }
2441
- };
2442
- //#endregion
2443
- //#region src/fs/list-files.ts
2444
- /**
2445
- * List every file under `dir`, recursively, as paths relative to `dir` (using the
2446
- * platform separator). Only files are returned, never directories. Order is not
2447
- * guaranteed. Used to copy a whole skill directory — multi-file skills (a
2448
- * `reference.md`, `scripts/`) keep their resources, not just `SKILL.md`.
2449
- */
2450
- const listFiles = async (dir) => {
2451
- const entries = await readdir(dir, { withFileTypes: true });
2452
- return (await Promise.all(entries.map(async (entry) => {
2453
- if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
2454
- return [entry.name];
2455
- }))).flat();
2456
- };
2457
- //#endregion
2458
- //#region src/fs/prune-empty-dirs.ts
2459
- /**
2460
- * Remove the now-empty managed dirs that removing a set of files left behind (an
2461
- * empty `.claude/skills/<name>/`, `.claude/commands/`, `.claude/agents/`, …). Walks
2462
- * each removed file's ancestry deepest-first and `rmdir`s only empty dirs — a dir
2463
- * that still holds a client file (or `.claude/state`) throws ENOTEMPTY and is left
2464
- * alone. Shared by `uninstall` (whole-tree removal) and the reconcile prune path
2465
- * (`update` / `repair` dropping orphaned vendor files), so a cohesive skill
2466
- * directory never lingers empty after its files go.
2467
- */
2468
- const pruneEmptyDirs = async (repoRoot, removedFiles) => {
2469
- const dirs = /* @__PURE__ */ new Set();
2470
- for (const relPath of removedFiles) {
2471
- let dir = dirname(relPath);
2472
- while (dir !== "." && dir !== sep) {
2473
- dirs.add(dir);
2474
- dir = dirname(dir);
2475
- }
2476
- }
2477
- const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
2478
- for (const dir of ordered) try {
2479
- await rmdir(join(repoRoot, dir));
2480
- } catch {}
2481
- };
2482
- //#endregion
2483
- //#region src/fs/write-managed.ts
2484
- /**
2485
- * Write a managed file under `root` at a repo-relative path — the one definition of
2486
- * "a managed file is content + exec mode, parent dirs created". Shared by `install`-
2487
- * adjacent writers (`update` apply, `rollback` restore, snapshot working tree) so the
2488
- * mode policy lives in one place.
2489
- *
2490
- * The mode is set **unconditionally**: `writeFile` preserves an existing file's bits,
2491
- * so a file the new catalog flips executable→plain would otherwise keep a stale `+x`.
2492
- * chmod both ways keeps the on-disk mode tracking the source.
2493
- */
2494
- const writeManaged = async (root, relPath, content, executable) => {
2495
- const dest = join(root, relPath);
2496
- await mkdir(dirname(dest), { recursive: true });
2497
- await writeFile(dest, content);
2498
- await chmod(dest, executable ? 493 : 420);
2499
- };
2500
- //#endregion
2501
- //#region src/fs/symlink-guard.ts
2502
- /**
2503
- * Refuse the whole operation if any managed path traverses a **symlink** component
2504
- * (P7/S2, closing the S1 deferral). Lexical `..` traversal is already closed; this
2505
- * shuts the symbolic vector: a committed/poisoned file at a managed path (e.g.
2506
- * `.claude/agents/x` → `/etc/cron.d`, lexically clean) would otherwise let the
2507
- * subsequent `writeFile`/`rm` land OUTSIDE the repo, at the user's privilege.
2508
- *
2509
- * Every path component from `root` down to and including the leaf is `lstat`'d: a
2510
- * symlink at any level (a parent dir or the file itself, which `writeFile` would
2511
- * follow) is an offender. Components that don't exist yet (`mkdir` will create them)
2512
- * are fine. Pre-flight by design — it refuses before ANY write, never half-applies.
2513
- *
2514
- * **TOCTOU is out of scope, by design.** The check (`lstat`) and the use
2515
- * (`writeFile`/`rm`) are separate syscalls, so a local process racing the CLI could
2516
- * swap a checked dir for a symlink in the window between them. Closing that would
2517
- * need `O_NOFOLLOW`/`openat` on every component at every writer — and the parent-dir
2518
- * vector still would not fully close. Under the trust model (local dev, single user)
2519
- * the racing process already runs at the user's privilege, so the symlink gains it
2520
- * nothing it could not do directly; this guard is defense-in-depth against a
2521
- * *poisoned committed path*, not a concurrent attacker.
2522
- */
2523
- const assertNoSymlinkTraversal = async (root, relPaths) => {
2524
- const components = /* @__PURE__ */ new Set();
2525
- for (const relPath of relPaths) {
2526
- let current = "";
2527
- for (const part of relPath.split(sep)) {
2528
- if (part.length === 0) continue;
2529
- current = current ? join(current, part) : part;
2530
- components.add(current);
2531
- }
2532
- }
2533
- const offenders = (await Promise.all([...components].map(async (rel) => {
2534
- try {
2535
- return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
2536
- } catch {
2537
- return null;
2538
- }
2539
- }))).filter((rel) => rel !== null);
2540
- 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"));
2541
- };
2542
- //#endregion
2543
2784
  //#region src/install/devdependency.ts
2544
2785
  const inspectDevDependency = async (repoRoot) => {
2545
2786
  const pkgPath = join(repoRoot, "package.json");
@@ -2553,36 +2794,12 @@ const inspectDevDependency = async (repoRoot) => {
2553
2794
  };
2554
2795
  //#endregion
2555
2796
  //#region src/scan/scan.constant.ts
2556
- /**
2557
- * The repo-relative convention path the scan looks for to set `hasArchitectureDoc`
2558
- * (which gates the `update-architecture` skill via `applies-when: has-architecture-doc`).
2559
- *
2560
- * Lives here, not inline in `scan.ts`, so the same literal is the single source for
2561
- * both the detection (`scanRepo`) and the human-facing latent-capability report
2562
- * (`skills.ts` `CAPABILITY_REGISTRY` → `trigger`). Forward-slash so it doubles as the
2563
- * display string; `join(root, …)` normalizes separators for the on-disk probe.
2564
- */
2565
2797
  const ARCHITECTURE_DOC_PATH = "docs/architecture.md";
2566
- /**
2567
- * The `package.json` script name whose presence sets `hasMutationTesting` (which gates
2568
- * the `mutation-testing` skill via `applies-when: has-mutation-testing`, #155). Unlike
2569
- * `ARCHITECTURE_DOC_PATH` this is NOT an on-disk path — the probe reads
2570
- * `package.json` → `scripts[MUTATION_SCRIPT_NAME]` — so its human-facing display string
2571
- * is built separately in the `CAPABILITY_REGISTRY` `trigger`. The harness stays
2572
- * tool-agnostic: the script may run Stryker or any mutation tool; only the conventional
2573
- * name is fixed, mirroring how `verify` delegates to the project's declared scripts.
2574
- */
2575
2798
  const MUTATION_SCRIPT_NAME = "test:mutation";
2576
2799
  //#endregion
2577
2800
  //#region src/scan/scan.ts
2578
2801
  const ORIGIN_URL = /\[remote "origin"\][^[]*?url\s*=\s*(\S+)/;
2579
2802
  const SLUG_FROM_URL = /[/:]([^/:]+)\/([^/]+)$/;
2580
- /**
2581
- * Extract the `owner/name` slug from the origin remote url in a `.git/config`
2582
- * body. Handles both `https://host/owner/name(.git)` and
2583
- * `git@host:owner/name(.git)`. Returns null when there is no origin remote or
2584
- * the url is unparseable.
2585
- */
2586
2803
  const parseOriginSlug = (gitConfig) => {
2587
2804
  const url = ORIGIN_URL.exec(gitConfig)?.[1];
2588
2805
  if (!url) return null;
@@ -2592,13 +2809,6 @@ const parseOriginSlug = (gitConfig) => {
2592
2809
  if (!owner || !name) return null;
2593
2810
  return `${owner}/${name}`;
2594
2811
  };
2595
- /**
2596
- * Read `.git/config` and return the origin remote slug (`owner/name`), or null
2597
- * when the file is missing, the repo has no origin, or the url is unparseable.
2598
- * Exposed so callers that only need the slug (the install CLI resolving
2599
- * `task_storage.repo` before deciding whether to prompt) skip the full
2600
- * capability scan.
2601
- */
2602
2812
  const readOriginSlug = async (root) => {
2603
2813
  try {
2604
2814
  return parseOriginSlug(await readFile(join(root, ".git", "config"), "utf8"));
@@ -2606,11 +2816,6 @@ const readOriginSlug = async (root) => {
2606
2816
  return null;
2607
2817
  }
2608
2818
  };
2609
- /**
2610
- * Whether `package.json` declares a `test:mutation` script (#155). Reads content
2611
- * (not just existence) and tolerates a missing/malformed manifest — a non-Node repo,
2612
- * absent `scripts`, or unparseable JSON all read as "no mutation testing", never throw.
2613
- */
2614
2819
  const hasMutationScript = async (root) => {
2615
2820
  try {
2616
2821
  const script = JSON.parse(await readFile(join(root, "package.json"), "utf8")).scripts?.[MUTATION_SCRIPT_NAME];
@@ -2619,7 +2824,6 @@ const hasMutationScript = async (root) => {
2619
2824
  return false;
2620
2825
  }
2621
2826
  };
2622
- /** Detect the repo capabilities the installer needs to render templates and gate skills. */
2623
2827
  const scanRepo = async (root) => {
2624
2828
  const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc, hasMutationTesting] = await Promise.all([
2625
2829
  pathExists(join(root, ".git")),
@@ -2643,23 +2847,14 @@ const scanRepo = async (root) => {
2643
2847
  };
2644
2848
  //#endregion
2645
2849
  //#region src/skills/frontmatter.ts
2646
- /** Leading `---\n … \n---` block at the very start of a markdown document. */
2647
2850
  const FRONTMATTER_BLOCK$1 = /^---\n([\s\S]*?)\n---/;
2648
- /** A `key: value` line; the key is the first dash/word token before the colon. */
2649
2851
  const FIELD_LINE = /^([\w-]+)\s*:\s*(.*)$/;
2650
- /** Parse a `[a, b, c]` inline list, or return the raw scalar untouched. */
2651
2852
  const parseValue = (raw) => {
2652
2853
  if (!(raw.startsWith("[") && raw.endsWith("]"))) return raw;
2653
2854
  const inner = raw.slice(1, -1).trim();
2654
2855
  if (inner === "") return [];
2655
2856
  return inner.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
2656
2857
  };
2657
- /**
2658
- * Read the leading frontmatter of a markdown document into a flat record. A
2659
- * deliberately minimal YAML subset — scalars and inline `[a, b]` lists — which is
2660
- * all the skill catalog uses; no dependency, matching `renderTemplate`'s style.
2661
- * Returns `{}` when the document has no frontmatter block.
2662
- */
2663
2858
  const parseFrontmatter = (content) => {
2664
2859
  const block = FRONTMATTER_BLOCK$1.exec(content)?.[1];
2665
2860
  if (block === void 0) return {};
@@ -2675,7 +2870,6 @@ const parseFrontmatter = (content) => {
2675
2870
  };
2676
2871
  //#endregion
2677
2872
  //#region src/skills/skills.constant.ts
2678
- /** Human label for each phase, used when rendering the `{{SKILLS}}` block. */
2679
2873
  const PHASE_LABELS = {
2680
2874
  "pre-implementation": "Pre-implementation",
2681
2875
  "during-implementation": "During implementation",
@@ -2683,11 +2877,6 @@ const PHASE_LABELS = {
2683
2877
  };
2684
2878
  //#endregion
2685
2879
  //#region src/skills/skills.model.ts
2686
- /**
2687
- * Implementation-lifecycle slot a skill occupies in a sub-agent's `{{SKILLS}}`
2688
- * marker. A skill with no phase is **universal** (e.g. `raise-discovery`) and is
2689
- * listed under every sub-agent that invokes it, regardless of lifecycle position.
2690
- */
2691
2880
  const PHASES = [
2692
2881
  "pre-implementation",
2693
2882
  "during-implementation",
@@ -2695,14 +2884,6 @@ const PHASES = [
2695
2884
  ];
2696
2885
  //#endregion
2697
2886
  //#region src/skills/skills.ts
2698
- /**
2699
- * The registry of opt-in (`applies-when`-gated) capabilities, keyed by capability key.
2700
- * The **capability** owns its metadata here (decision #136): the `predicate` over the
2701
- * scan (the install-time gate, decision #31/#39), the `trigger` convention path the
2702
- * client creates to satisfy it, and a short human `label` for what it unlocks. The
2703
- * skills a key unlocks are NOT listed here — they're derived from the catalog (any skill
2704
- * whose `applies-when` includes the key), so adding a skill never desyncs this table.
2705
- */
2706
2887
  const CAPABILITY_REGISTRY = {
2707
2888
  "has-architecture-doc": {
2708
2889
  predicate: (caps) => caps.hasArchitectureDoc,
@@ -2715,12 +2896,6 @@ const CAPABILITY_REGISTRY = {
2715
2896
  label: "check test strength with mutation testing in review"
2716
2897
  }
2717
2898
  };
2718
- /**
2719
- * Whether a single `applies-when` capability key holds for the scanned repo, failing
2720
- * loud on an unknown key — a skill referencing a capability with no registry entry is a
2721
- * catalog bug, not a silent under-gate. Shared by `appliesSatisfied` and
2722
- * `latentCapabilities` so both agree on key validity.
2723
- */
2724
2899
  const capabilityHolds = (key, caps) => {
2725
2900
  const entry = CAPABILITY_REGISTRY[key];
2726
2901
  if (!entry) throw new Error(`unknown applies-when capability key "${key}"`);
@@ -2729,17 +2904,11 @@ const capabilityHolds = (key, caps) => {
2729
2904
  const asString = (value) => typeof value === "string" ? value : void 0;
2730
2905
  const asList = (value) => Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
2731
2906
  const isPhase = (value) => PHASES.includes(value);
2732
- /**
2733
- * Read an enum-valued frontmatter field, failing loud on a malformed value — a
2734
- * mistyped enum (`phase: [post-implementation]`) must error, not silently default
2735
- * and mis-place the skill.
2736
- */
2737
2907
  const readEnumField = (skillName, field, value, isValid) => {
2738
2908
  if (value === void 0) return void 0;
2739
2909
  if (typeof value !== "string" || !isValid(value)) throw new Error(`skill "${skillName}": invalid ${field} ${JSON.stringify(value)}`);
2740
2910
  return value;
2741
2911
  };
2742
- /** Build a `SkillMeta` from a skill's parsed frontmatter, validating enums. */
2743
2912
  const toSkillMeta = (name, frontmatter) => ({
2744
2913
  name,
2745
2914
  phase: readEnumField(name, "phase", frontmatter.phase, isPhase),
@@ -2747,11 +2916,6 @@ const toSkillMeta = (name, frontmatter) => ({
2747
2916
  appliesWhen: asList(frontmatter["applies-when"]),
2748
2917
  triggerCondition: asString(frontmatter["trigger-condition"])
2749
2918
  });
2750
- /**
2751
- * Read every skill in the vendor catalog (`<catalogRoot>/skills/<name>/SKILL.md`)
2752
- * into gating metadata, sorted by name for deterministic output. Non-directory
2753
- * entries (the catalog `README.md`) are skipped.
2754
- */
2755
2919
  const readCatalogSkills = async (catalogRoot) => {
2756
2920
  const skillsRoot = join(catalogRoot, "skills");
2757
2921
  const entries = await readdir(skillsRoot, { withFileTypes: true });
@@ -2760,7 +2924,6 @@ const readCatalogSkills = async (catalogRoot) => {
2760
2924
  return toSkillMeta(entry.name, parseFrontmatter(content));
2761
2925
  }))).toSorted((a, b) => a.name.localeCompare(b.name));
2762
2926
  };
2763
- /** Whether every `applies-when` key of a skill holds for the scanned repo. */
2764
2927
  const appliesSatisfied = (skill, caps) => skill.appliesWhen.every((key) => {
2765
2928
  try {
2766
2929
  return capabilityHolds(key, caps);
@@ -2768,25 +2931,7 @@ const appliesSatisfied = (skill, caps) => skill.appliesWhen.every((key) => {
2768
2931
  throw new Error(`skill "${skill.name}": ${error.message}`, { cause: error });
2769
2932
  }
2770
2933
  });
2771
- /**
2772
- * The skills that install for a repo: every `appliesWhen` capability key must hold
2773
- * (#31 — install-time, deterministic). A skill with no `appliesWhen` is always
2774
- * installed (the profile tier #39 is retired — ADR 0015). `triggerCondition` is
2775
- * per-change and stays a runtime concern, not evaluated here.
2776
- */
2777
2934
  const selectSkills = (skills, capabilities) => skills.filter((skill) => appliesSatisfied(skill, capabilities));
2778
- /**
2779
- * The opt-in capabilities that are **available but not active** for this repo
2780
- * (decision #136): a registry key whose predicate is currently false AND that is the sole
2781
- * blocker for ≥1 otherwise-eligible skill (every *other* `applies-when` key already
2782
- * satisfied). Those skills would land the moment the client creates the `trigger`
2783
- * convention file — so reporting them makes the convention discoverable without the
2784
- * harness creating the artifact (decision #8).
2785
- *
2786
- * Pure and shared by `install` and `doctor` so both surface the same set by construction.
2787
- * A capability whose predicate already holds is omitted (it's active, not latent); one
2788
- * that gates no skill is omitted too (creating the file would unlock nothing here).
2789
- */
2790
2935
  const latentCapabilities = (skills, capabilities) => Object.entries(CAPABILITY_REGISTRY).toSorted(([a], [b]) => a.localeCompare(b)).filter(([, entry]) => !entry.predicate(capabilities)).flatMap(([key, entry]) => {
2791
2936
  const unlocked = skills.filter((skill) => skill.appliesWhen.includes(key) && skill.appliesWhen.every((other) => {
2792
2937
  if (other === key) return true;
@@ -2805,12 +2950,6 @@ const latentCapabilities = (skills, capabilities) => Object.entries(CAPABILITY_R
2805
2950
  }];
2806
2951
  });
2807
2952
  const renderSkillLine = (skill) => skill.triggerCondition ? `- \`${skill.name}\` — only when ${skill.triggerCondition}` : `- \`${skill.name}\``;
2808
- /**
2809
- * Render a sub-agent's `{{SKILLS}}` marker from the selected skills it invokes,
2810
- * grouped by phase in lifecycle order with universal (phase-less) skills last.
2811
- * Falls back to a sentinel line when the role has no skills so the
2812
- * section never renders empty.
2813
- */
2814
2953
  const renderRoleSkills = (selected, role) => {
2815
2954
  const roleSkills = selected.filter((skill) => skill.invokedBy.includes(role));
2816
2955
  const groups = [];
@@ -2825,13 +2964,9 @@ const renderRoleSkills = (selected, role) => {
2825
2964
  };
2826
2965
  //#endregion
2827
2966
  //#region src/doctor/cli-bin.constant.ts
2828
- /** The telemetry CLI's executable name — the bin the launcher resolves
2829
- * (`node_modules/.bin/lemony` or a `lemony` on PATH). */
2830
2967
  const LEMONY_BIN = "lemony";
2831
2968
  //#endregion
2832
2969
  //#region src/doctor/resolve-cli.ts
2833
- /** True when `path` exists and is executable — mirrors the launcher's `[ -x ]`
2834
- * (both follow symlinks, so a pnpm `.bin` symlink resolves). */
2835
2970
  const isExecutable = async (path) => {
2836
2971
  try {
2837
2972
  await access(path, constants.X_OK);
@@ -2840,19 +2975,6 @@ const isExecutable = async (path) => {
2840
2975
  return false;
2841
2976
  }
2842
2977
  };
2843
- /**
2844
- * Whether a global `lemony` binary is resolvable on `PATH` — the doctor's
2845
- * pure-Node mirror of the launcher's `command -v lemony` (the launcher's second
2846
- * resolution step, `catalog/hooks/lib/lemony.sh`). For a plain binary on a normal
2847
- * `PATH` a walk matches `command -v`, and it avoids spawning a shell — the doctor runs in
2848
- * the same session env as the hook, so its `process.env.PATH` is the very `PATH` the
2849
- * launcher would search. (Pathological entries diverge — a *directory* named
2850
- * `lemony`, or a non-executable file — but only ever change the `warn` detail
2851
- * wording, never the verdict, which turns solely on the project-local bin.)
2852
- *
2853
- * Pure and injectable: pass an explicit `pathEnv` / `isExe` so a spec can script the
2854
- * outcome against a fixture dir without depending on a real global install.
2855
- */
2856
2978
  const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
2857
2979
  if (!pathEnv) return false;
2858
2980
  for (const dir of pathEnv.split(delimiter)) {
@@ -2863,26 +2985,12 @@ const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) =>
2863
2985
  };
2864
2986
  //#endregion
2865
2987
  //#region src/doctor/doctor.ts
2866
- /**
2867
- * The vendor hook commands every harnessed repo must wire in `.claude/settings.json`
2868
- * (decision B4.3 — checked at presence level, not a deep merge-settings diff). One
2869
- * per Claude Code event the harness owns.
2870
- */
2871
2988
  const VENDOR_HOOK_COMMANDS = [
2872
2989
  ".claude/hooks/init.sh",
2873
2990
  ".claude/hooks/session-close.sh",
2874
2991
  ".claude/hooks/require-playbook.sh",
2875
2992
  ".claude/hooks/suggest-playbook.sh"
2876
2993
  ];
2877
- /**
2878
- * Read-only health check (decision B4.2/B4.3): eight diagnostics that **propose**
2879
- * the exact remediation but never mutate. Now that `install` is fresh-only
2880
- * (ADR 0005), label/hook drift points at `lemony repair` (a re-sync at the
2881
- * pinned version, preserving client edits) and version drift at `lemony
2882
- * update`. The config check reuses the Zod
2883
- * validator; the label check reuses the shared pure `diffLabels`; nothing here
2884
- * writes to disk or to GitHub.
2885
- */
2886
2994
  const runDoctor = async (deps) => {
2887
2995
  const { repoRoot } = deps;
2888
2996
  const checks = [];
@@ -2909,6 +3017,7 @@ const runDoctor = async (deps) => {
2909
3017
  checks.push(checkVersionPin(deps, config));
2910
3018
  checks.push(await checkCliResolution(deps));
2911
3019
  checks.push(await checkCapabilities(deps, config));
3020
+ checks.push(await checkDesignToolDrift(deps));
2912
3021
  return {
2913
3022
  checks,
2914
3023
  ok: checks.every((check) => check.status !== "fail")
@@ -3066,18 +3175,6 @@ const checkVersionPin = (deps, config) => {
3066
3175
  remediation: "If your CLI is newer than the pin, run `lemony update`; if it is older, upgrade your CLI and do NOT run `update` (it would downgrade the repo)."
3067
3176
  };
3068
3177
  };
3069
- /**
3070
- * Telemetry CLI resolvable via the launcher (#123). The launcher
3071
- * (`catalog/hooks/lib/lemony.sh`) resolves the CLI `node_modules/.bin` →
3072
- * global on PATH → fail-fast (exit 127, no npx — #107/#113/#124). This check is a
3073
- * read-only dry-run of that exact chain, so doctor and the launcher agree by
3074
- * construction: a `node_modules/.bin/lemony` (the durable, package-manager-
3075
- * agnostic setup — pnpm symlinks a direct dep's bin there too) is `ok`; everything
3076
- * else is `warn` (telemetry is best-effort/fail-open, never a hard `fail`), with a
3077
- * remediation tuned to why resolution is fragile. No network — the common path never
3078
- * triggers an install. Resolution is `repoRoot`-relative (cwd, like every other doctor
3079
- * check) — run from the harness repo root, not a subdirectory.
3080
- */
3081
3178
  const checkCliResolution = async (deps) => {
3082
3179
  const name = "cli-resolution";
3083
3180
  if (await isExecutable(join(deps.repoRoot, "node_modules", ".bin", "lemony"))) return {
@@ -3106,21 +3203,6 @@ const checkCliResolution = async (deps) => {
3106
3203
  remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
3107
3204
  };
3108
3205
  };
3109
- /**
3110
- * Opt-in (`applies-when`-gated) capabilities available but not active (#136): each names
3111
- * a convention file the repo could create to unlock a skill that didn't install. Status
3112
- * `info` when ≥1 is latent (opt-in-by-design, never a fault — #8 blesses not keeping the
3113
- * file), `ok` when every applicable capability is already active. Re-derives the set from
3114
- * a fresh scan + the catalog, so it agrees with `install`'s report by construction. Skips
3115
- * (`warn`) when config didn't validate (a broken install isn't worth advising on).
3116
- *
3117
- * Reading the catalog is the only doctor check that touches the vendor bundle, so it is
3118
- * also the only one that a damaged/relocated install can make throw (ENOENT on
3119
- * `<vendorRoot>/skills`, or a catalog skill with an unknown `applies-when` key). Guarded
3120
- * so such a failure degrades to a single `warn` instead of crashing `runDoctor` and
3121
- * taking the other seven diagnostics down with it — a health tool must survive a broken
3122
- * install, which is exactly when it's run.
3123
- */
3124
3206
  const checkCapabilities = async (deps, config) => {
3125
3207
  const name = "capabilities";
3126
3208
  if (!config) return {
@@ -3150,20 +3232,63 @@ const checkCapabilities = async (deps, config) => {
3150
3232
  name,
3151
3233
  status: "info",
3152
3234
  detail: `${latent.length} opt-in ${latent.length === 1 ? "capability" : "capabilities"} available — ${lines}.`,
3153
- remediation: "Run /add-capability in Claude Code to activate one — the Architect authors the artifact, then `repair` installs the skill (#8: opt-in, never imposed)."
3235
+ remediation: "Run /add-capability in Claude Code to activate one — the Architect authors the artifact, then `repair` installs the skill (opt-in, never imposed)."
3154
3236
  };
3155
3237
  };
3238
+ const checkDesignToolDrift = async (deps) => {
3239
+ const name = "design-tool-drift";
3240
+ const tokenPath = join(deps.repoRoot, DESIGN_TOKENS_FILE);
3241
+ if (!await pathExists(tokenPath)) return {
3242
+ name,
3243
+ status: "info",
3244
+ detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
3245
+ };
3246
+ let parsed;
3247
+ try {
3248
+ parsed = JSON.parse(await readFile(tokenPath, "utf8"));
3249
+ } catch (error) {
3250
+ return {
3251
+ name,
3252
+ status: "warn",
3253
+ detail: `Could not read ${DESIGN_TOKENS_FILE}: ${error.message}`,
3254
+ remediation: "Run `lemony design-tokens validate` to find the problem."
3255
+ };
3256
+ }
3257
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
3258
+ name,
3259
+ status: "warn",
3260
+ detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
3261
+ remediation: "Run `lemony design-tokens validate` to find the problem."
3262
+ };
3263
+ const report = driftReport(parsed);
3264
+ switch (report.state) {
3265
+ case "n/a": return {
3266
+ name,
3267
+ status: "info",
3268
+ detail: "No design tool declared — token sync not in use."
3269
+ };
3270
+ case "never-projected": return {
3271
+ name,
3272
+ status: "info",
3273
+ detail: `Design tool "${report.provider}" declared; no projection baseline yet.`,
3274
+ remediation: "Run /sync-design-tokens export in Claude Code to bootstrap the tool."
3275
+ };
3276
+ case "in-sync": return {
3277
+ name,
3278
+ status: "ok",
3279
+ detail: `Tokens are in sync with the "${report.provider}" design tool.`
3280
+ };
3281
+ case "export-pending": return {
3282
+ name,
3283
+ status: "warn",
3284
+ detail: `Tokens changed since the last projection to "${report.provider}" — an export is pending.`,
3285
+ remediation: "Run /sync-design-tokens export in Claude Code to project the change to the tool."
3286
+ };
3287
+ }
3288
+ };
3156
3289
  //#endregion
3157
3290
  //#region src/status/status.ts
3158
- /** The state label whose presence on an open issue marks a live discovery (#55c/e). */
3159
3291
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
3160
- /**
3161
- * Build the on-demand orient summary (decision B4.1) — purely read-only. Reads
3162
- * the config (version/slug), the per-dev `current-<user>.md` pointer
3163
- * (active task, last session), git (branch + behind count, local refs only), and
3164
- * — best-effort via `gh` — the count of open issues paused on a discovery. Every
3165
- * source degrades gracefully to null so a partial environment still prints.
3166
- */
3167
3292
  const runStatus = async (deps) => {
3168
3293
  const { repoRoot } = deps;
3169
3294
  const config = await readHarnessConfig(repoRoot);
@@ -3177,10 +3302,21 @@ const runStatus = async (deps) => {
3177
3302
  branch: branch ?? pointer.branch,
3178
3303
  behind,
3179
3304
  lastSession: pointer.lastSession,
3180
- openDiscoveries
3305
+ openDiscoveries,
3306
+ designToolDrift: await readDriftState(repoRoot)
3181
3307
  };
3182
3308
  };
3183
- /** Read `active_task` / `branch` / `last_close_ts` from the per-dev pointer frontmatter. */
3309
+ const readDriftState = async (repoRoot) => {
3310
+ const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
3311
+ if (!await pathExists(tokenPath)) return null;
3312
+ try {
3313
+ const parsed = JSON.parse(await readFile(tokenPath, "utf8"));
3314
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
3315
+ return driftReport(parsed).state;
3316
+ } catch {
3317
+ return null;
3318
+ }
3319
+ };
3184
3320
  const readPointer = async (repoRoot, readGitUserEmail) => {
3185
3321
  const empty = {
3186
3322
  activeTask: null,
@@ -3209,13 +3345,11 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
3209
3345
  lastSession: normalize(front.last_close_ts)
3210
3346
  };
3211
3347
  };
3212
- /** YAML `null`, the literal string "null", and "" all read as "unset". */
3213
3348
  const normalize = (value) => {
3214
3349
  if (value === null || value === void 0) return null;
3215
3350
  const trimmed = String(value).trim();
3216
3351
  return trimmed === "" || trimmed === "null" ? null : trimmed;
3217
3352
  };
3218
- /** Count open issues paused on a discovery; null on any `gh` failure (best-effort). */
3219
3353
  const countOpenDiscoveries = async (provider, slug) => {
3220
3354
  const result = await provider.listIssues(slug, {
3221
3355
  state: "open",
@@ -3228,11 +3362,6 @@ const countOpenDiscoveries = async (provider, slug) => {
3228
3362
  return null;
3229
3363
  }
3230
3364
  };
3231
- /**
3232
- * The CLI's command surface, in the order they print. This is the single source
3233
- * of truth for both the top-level command list and per-command help; the verbs
3234
- * here must stay in lockstep with the `main` dispatcher in `cli.ts`.
3235
- */
3236
3365
  const COMMANDS = [
3237
3366
  {
3238
3367
  name: "install",
@@ -3279,6 +3408,11 @@ const COMMANDS = [
3279
3408
  summary: "Reflect a raised/resolved discovery onto its issue (label flip + comment); used by the Orchestrator's resolve-discovery skill.",
3280
3409
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
3281
3410
  },
3411
+ {
3412
+ name: "design-tokens",
3413
+ summary: "Validate the 3-tier design-token file (and gate UI source against hardcoded values), check WCAG contrast of token pairs, or sync tokens with a design tool (consume-if-exists; never created).",
3414
+ usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
3415
+ },
3282
3416
  {
3283
3417
  name: "spinoff",
3284
3418
  summary: "Capture a non-blocking defect found mid-task as a pending stub (+ followup_captured event); used by the /spinoff command.",
@@ -3293,7 +3427,6 @@ const COMMANDS = [
3293
3427
  //#endregion
3294
3428
  //#region src/help/help.ts
3295
3429
  const pad = (text, width) => text + " ".repeat(Math.max(0, width - text.length));
3296
- /** The top-level help: tagline, usage, the command table, and the footer hints. */
3297
3430
  const topLevelHelp = () => {
3298
3431
  const width = Math.max(...COMMANDS.map((c) => c.name.length));
3299
3432
  return [
@@ -3308,7 +3441,6 @@ const topLevelHelp = () => {
3308
3441
  "`lemony version` (or `--version` / `-v`) prints the installed CLI version."
3309
3442
  ].join("\n");
3310
3443
  };
3311
- /** Per-command help (usage + summary), or `undefined` for an unknown command. */
3312
3444
  const commandHelp = (name) => {
3313
3445
  const command = COMMANDS.find((c) => c.name === name);
3314
3446
  if (!command) return void 0;
@@ -3318,56 +3450,21 @@ const commandHelp = (name) => {
3318
3450
  command.summary
3319
3451
  ].join("\n");
3320
3452
  };
3321
- /**
3322
- * The unknown-command message. Names the bad input and points at `--help` rather
3323
- * than dumping the full command list, so discovery has one obvious entry point.
3324
- */
3325
3453
  const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
3326
3454
  //#endregion
3327
3455
  //#region src/render/render.ts
3328
3456
  const PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/g;
3329
- /**
3330
- * Render a template by substituting every `{{ key }}` placeholder with the
3331
- * matching value from `vars`. Surrounding whitespace and dotted keys
3332
- * (`{{ task_storage.repo }}`) are tolerated. Throws when a placeholder has no
3333
- * matching var so a typo fails loudly instead of shipping a literal `{{ }}`.
3334
- */
3335
3457
  const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
3336
3458
  if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
3337
3459
  return vars[key];
3338
3460
  });
3339
3461
  //#endregion
3340
3462
  //#region src/baseline/baseline.ts
3341
- /**
3342
- * The baseline tree root — committed by default (decision D2/ADR 0005), so it
3343
- * travels with the repo and any teammate's `update` is a true 3-way merge. Lives
3344
- * under `.claude/.harness/` beside the gitignored `snapshots/` (added in S2).
3345
- */
3346
3463
  const BASELINE_ROOT_REL = join(".claude", ".harness", "baseline");
3347
- /**
3348
- * Where a new baseline is built before it is promoted. A dot-prefixed sibling of
3349
- * the version dirs so `findBaselineVersion` skips it: a half-written staging tree
3350
- * (from a crashed write) is never mistaken for a merge base.
3351
- */
3352
3464
  const STAGING_DIR$1 = ".staging";
3353
- /** True when a filesystem error is "the path does not exist". */
3354
3465
  const isENOENT$1 = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
3355
- /** The repo-relative root holding every version's baseline (only one at a time). */
3356
3466
  const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
3357
- /** The dir holding the pristine vendor tree for one version. */
3358
3467
  const baselineVersionDir = (repoRoot, version) => join(baselineRootDir(repoRoot), version);
3359
- /**
3360
- * Write the pristine vendor tree for `version`, mirroring each file's repo-relative
3361
- * path one level deeper. Only the current version is retained (D7).
3362
- *
3363
- * The write is crash-safe: the new tree is built **fully** under a dot-prefixed
3364
- * `.staging` dir (invisible to `findBaselineVersion`) and only then promoted into
3365
- * place with a single `rename` — the commit point. So a crash mid-write never
3366
- * leaves a half-written version dir to be read as a truncated merge base (which
3367
- * would silently route every absent file to the destructive pick-one path). The
3368
- * old version dir survives untouched until the new one is fully renamed in;
3369
- * `pruneOtherVersions` then enforces single-version retention.
3370
- */
3371
3468
  const writeBaseline = async (repoRoot, version, files) => {
3372
3469
  const root = baselineRootDir(repoRoot);
3373
3470
  const staging = join(root, STAGING_DIR$1);
@@ -3389,7 +3486,6 @@ const writeBaseline = async (repoRoot, version, files) => {
3389
3486
  await rename(staging, versionDir);
3390
3487
  await pruneOtherVersions(root, version);
3391
3488
  };
3392
- /** Remove every version dir under `root` except `keep` (single-version retention). */
3393
3489
  const pruneOtherVersions = async (root, keep) => {
3394
3490
  const entries = await readdir(root, { withFileTypes: true });
3395
3491
  await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
@@ -3397,11 +3493,6 @@ const pruneOtherVersions = async (root, keep) => {
3397
3493
  force: true
3398
3494
  })));
3399
3495
  };
3400
- /**
3401
- * Read a version's baseline back as a `relPath → content` map (the merge base for
3402
- * `update`). An absent version dir yields an empty map — the caller treats that as
3403
- * "no baseline" and degrades to the pick-one path (D2), never an error.
3404
- */
3405
3496
  const readBaseline = async (repoRoot, version) => {
3406
3497
  const versionDir = baselineVersionDir(repoRoot, version);
3407
3498
  let relPaths;
@@ -3414,25 +3505,6 @@ const readBaseline = async (repoRoot, version) => {
3414
3505
  const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
3415
3506
  return new Map(entries);
3416
3507
  };
3417
- /**
3418
- * The version whose baseline is installed, or null when none is present. Used by
3419
- * `update` to find the merge base and to detect absence (graceful degrade, D2).
3420
- *
3421
- * Single-version retention (D7) normally means exactly one version dir, and the
3422
- * common path returns it directly. Dot-prefixed dirs (`.staging` from an in-flight
3423
- * or crashed write, a stray `.DS_Store`) are skipped so a half-written tree is
3424
- * never read as the merge base.
3425
- *
3426
- * A second version dir only appears in the crash window between promoting a new
3427
- * baseline (`rename`) and pruning the old — and the merge base we want there is the
3428
- * just-promoted tree (the client's files were already merged against it). We can't
3429
- * derive "just-promoted" from the version NAME — a prerelease→release bump
3430
- * (`0.1.0-alpha.0`→`0.1.0`) or a downgrade/repair would mis-order it — so we pick
3431
- * the most-recently-written dir by mtime (the promoted dir carries the staging
3432
- * tree's fresh mtime), with numeric version as a stable tiebreak. A genuinely-absent
3433
- * root degrades to null; an unreadable one propagates (it must not look like "no
3434
- * baseline").
3435
- */
3436
3508
  const findBaselineVersion = async (repoRoot) => {
3437
3509
  const root = baselineRootDir(repoRoot);
3438
3510
  let entries;
@@ -3453,31 +3525,13 @@ const findBaselineVersion = async (repoRoot) => {
3453
3525
  };
3454
3526
  //#endregion
3455
3527
  //#region src/snapshot/snapshot.ts
3456
- /**
3457
- * The snapshots root — gitignored (decision #48), unlike the committed baseline.
3458
- * Holds one `<from-version>/` bundle per `update`, rotated by `keep_snapshots`.
3459
- * Lives under `.claude/.harness/` beside the committed `baseline/`.
3460
- */
3461
3528
  const SNAPSHOTS_ROOT_REL = join(".claude", ".harness", "snapshots");
3462
- /** Where a bundle is built before promotion — a dot-dir `listSnapshots` skips. */
3463
3529
  const STAGING_DIR = ".staging";
3464
- /** The two file-tree halves of a bundle (ADR 0005 D7). */
3465
3530
  const WORKING_SUBDIR = "working";
3466
3531
  const BASELINE_SUBDIR = "baseline";
3467
- /** True when a filesystem error is "the path does not exist". */
3468
3532
  const isENOENT = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
3469
- /** The repo-relative root holding every retained snapshot bundle. */
3470
3533
  const snapshotsRootDir = (repoRoot) => join(repoRoot, SNAPSHOTS_ROOT_REL);
3471
- /** The dir holding one version's two-part snapshot bundle. */
3472
3534
  const snapshotDir = (repoRoot, version) => join(snapshotsRootDir(repoRoot), version);
3473
- /**
3474
- * Read the on-disk files at `relPaths` into a `VendorFile[]`, capturing each one's
3475
- * content and exec mode — the shape a snapshot's `working` half needs. Paths absent on
3476
- * disk are skipped, so the result mirrors the actual current state, nothing more. Shared
3477
- * by every writer that captures a pre-change tree: `reconcile` snapshots the whole
3478
- * baseline registry before an update; `install` snapshots only the client files a
3479
- * vendor-win collision is about to displace (decision D8).
3480
- */
3481
3535
  const collectWorkingFiles = async (repoRoot, relPaths) => {
3482
3536
  return (await Promise.all(relPaths.map(async (relPath) => {
3483
3537
  const filePath = join(repoRoot, relPath);
@@ -3490,15 +3544,9 @@ const collectWorkingFiles = async (repoRoot, relPaths) => {
3490
3544
  };
3491
3545
  }))).filter((file) => file !== null);
3492
3546
  };
3493
- /**
3494
- * Write a `VendorFile[]` tree under `destRoot`, preserving each file's exec mode via
3495
- * the shared `writeManaged`. The persisted exec bit is what lets an offline `rollback`
3496
- * restore a runnable hook with no `vendorNew` to re-derive the mode from.
3497
- */
3498
3547
  const writeWorkingTree = async (destRoot, files) => {
3499
3548
  await Promise.all(files.map(({ relPath, content, executable }) => writeManaged(destRoot, relPath, content, executable)));
3500
3549
  };
3501
- /** Write a baseline map under `destRoot` (content only — a merge base is never run). */
3502
3550
  const writeBaselineTree = async (destRoot, baseline) => {
3503
3551
  await Promise.all([...baseline].map(async ([relPath, content]) => {
3504
3552
  const dest = join(destRoot, relPath);
@@ -3506,15 +3554,6 @@ const writeBaselineTree = async (destRoot, baseline) => {
3506
3554
  await writeFile(dest, content);
3507
3555
  }));
3508
3556
  };
3509
- /**
3510
- * Every repo-relative path `writeSnapshot` writes to (or renames into), for the
3511
- * symlink pre-flight (#53). Covers both staging halves and the promote target dir —
3512
- * so the guard `lstat`s every shared ancestor (`.claude/.harness/snapshots[/.staging]`)
3513
- * plus each leaf. The two staging subdir roots and the version dir are included
3514
- * **unconditionally** so the `mkdir` targets and all ancestors are guarded even for an
3515
- * empty bundle — a half with no files would otherwise never pull its `working`/
3516
- * `baseline` subdir into the checked set.
3517
- */
3518
3557
  const snapshotDestPaths = (version, bundle) => {
3519
3558
  const stagingRel = join(SNAPSHOTS_ROOT_REL, STAGING_DIR);
3520
3559
  return [
@@ -3525,20 +3564,6 @@ const snapshotDestPaths = (version, bundle) => {
3525
3564
  join(SNAPSHOTS_ROOT_REL, version)
3526
3565
  ];
3527
3566
  };
3528
- /**
3529
- * Write the pre-update snapshot bundle for `version` (ADR 0005 D7). Crash-safe like
3530
- * `writeBaseline`: both halves are built **fully** under a dot-prefixed `.staging`
3531
- * dir (invisible to `listSnapshots`) and only then promoted with a single `rename`.
3532
- *
3533
- * Snapshots are keyed by the **from-version** they contain — exactly ONE bundle per
3534
- * version, not a full history. Distinct from-versions coexist (unlike the single-
3535
- * retention baseline) until `rotateSnapshots` prunes by age; but re-departing from the
3536
- * same version (a retried update, or an update after a rollback back to it) REPLACES
3537
- * that version's bundle. So a snapshot always holds the state immediately before the
3538
- * LAST departure from its version — which is precisely what `rollback`'s "undo the
3539
- * last update" contract restores. The committed vendor tree (git) remains the durable
3540
- * record of any earlier state.
3541
- */
3542
3567
  const writeSnapshot = async (repoRoot, version, bundle) => {
3543
3568
  const staging = join(snapshotsRootDir(repoRoot), STAGING_DIR);
3544
3569
  await assertNoSymlinkTraversal(repoRoot, snapshotDestPaths(version, bundle));
@@ -3557,13 +3582,6 @@ const writeSnapshot = async (repoRoot, version, bundle) => {
3557
3582
  });
3558
3583
  await rename(staging, dir);
3559
3584
  };
3560
- /**
3561
- * Every retained snapshot version, **newest-first by mtime** (so `rollback` with no
3562
- * `--to` targets the most recent, and `rotateSnapshots` keeps the freshest N).
3563
- * mtime — not version name — orders them, so a downgrade snapshot sorts by when it
3564
- * was taken, not by its (lower) version. Dot-dirs (`.staging`) are skipped; an
3565
- * absent root is an empty list.
3566
- */
3567
3585
  const listSnapshots = async (repoRoot) => {
3568
3586
  const root = snapshotsRootDir(repoRoot);
3569
3587
  let entries;
@@ -3581,11 +3599,6 @@ const listSnapshots = async (repoRoot) => {
3581
3599
  withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs || b.version.localeCompare(a.version, void 0, { numeric: true }));
3582
3600
  return withMtime.map((entry) => entry.version);
3583
3601
  };
3584
- /**
3585
- * Read a version's snapshot bundle back, or null when no bundle exists for it. The
3586
- * working half's exec bits are recovered from the on-disk file mode (round-tripping
3587
- * what `writeWorkingTree` chmod'd); the baseline half is content only.
3588
- */
3589
3602
  const readSnapshot = async (repoRoot, version) => {
3590
3603
  const dir = snapshotDir(repoRoot, version);
3591
3604
  if (!await pathExists(dir)) return null;
@@ -3608,11 +3621,6 @@ const readSnapshot = async (repoRoot, version) => {
3608
3621
  baseline: new Map(baselineEntries)
3609
3622
  };
3610
3623
  };
3611
- /**
3612
- * Enforce `keep_snapshots` retention: keep the `keep` newest bundles, remove the
3613
- * rest, and return the removed versions (oldest-first by exclusion). `'unlimited'`
3614
- * never rotates. A count at or above the current total removes nothing.
3615
- */
3616
3624
  const rotateSnapshots = async (repoRoot, keep) => {
3617
3625
  if (keep === "unlimited") return [];
3618
3626
  const toRemove = (await listSnapshots(repoRoot)).slice(keep);
@@ -3623,10 +3631,6 @@ const rotateSnapshots = async (repoRoot, keep) => {
3623
3631
  })));
3624
3632
  return toRemove;
3625
3633
  };
3626
- /**
3627
- * Remove every snapshot bundle (the `rollback --cleanup` path) and return the
3628
- * versions that were cleared. Drops the whole snapshots root; a no-op when absent.
3629
- */
3630
3634
  const cleanupSnapshots = async (repoRoot) => {
3631
3635
  const versions = await listSnapshots(repoRoot);
3632
3636
  await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
@@ -3638,49 +3642,15 @@ const cleanupSnapshots = async (repoRoot) => {
3638
3642
  };
3639
3643
  //#endregion
3640
3644
  //#region src/merge/conflict-markers.constant.ts
3641
- /**
3642
- * Git-style conflict markers (decision #49). `update`'s 3-way merge writes these
3643
- * around an overlapping hunk it can't auto-resolve; the client edits the file to
3644
- * pick a side and deletes the markers. The labels (`client`/`vendor`) name the two
3645
- * sides so the resolver knows which is theirs and which is the new vendor catalog.
3646
- */
3647
3645
  const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
3648
3646
  const CONFLICT_MARKER_SEPARATOR = "=======";
3649
3647
  const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
3650
3648
  //#endregion
3651
3649
  //#region src/merge/conflict-markers.ts
3652
- /**
3653
- * A line that is exactly one of the two labeled fences a conflict block opens and
3654
- * closes with. Detection keys on the full labeled fence — NOT a bare `=======`
3655
- * (a Markdown setext underline shares it) and NOT a generic `>>>>>>>` prefix (a
3656
- * seven-deep blockquote line `>>>>>>> note` shares that). Requiring the exact
3657
- * `client`/`vendor` label makes a match unambiguously a harness-authored marker,
3658
- * and either fence alone catches a half-resolved block (a deleted partner line).
3659
- */
3660
3650
  const CONFLICT_FENCE = new RegExp(`^(?:${CONFLICT_MARKER_CLIENT}|${CONFLICT_MARKER_VENDOR})$`, "m");
3661
- /**
3662
- * True when `content` carries a residual conflict fence — a marker a prior merge
3663
- * wrote that the client never resolved. `update` pre-scans every managed file with
3664
- * this and refuses to run while any remain (decision #49), so a second update can't
3665
- * bury an unresolved hunk inside a fresh one.
3666
- */
3667
3651
  const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
3668
3652
  //#endregion
3669
3653
  //#region src/merge/three-way-merge.ts
3670
- /**
3671
- * A line-based 3-way merge — the core of `update` (ADR 0005). Given the pristine
3672
- * vendor `base` (the baseline tree), the client's current file, and the new vendor
3673
- * file, it replays the client and vendor edits against the common base:
3674
- *
3675
- * - a region only one side touched is taken from that side (disjoint edits both land);
3676
- * - a region both sides changed **identically** is taken once;
3677
- * - a region both sides changed **differently** is wrapped in git-style markers and
3678
- * the result is flagged `conflicted` (decision #49 — never blocks, always reports).
3679
- *
3680
- * The algorithm is the classic diff3 over LCS anchors (Khanna–Kunal–Pierce): diff
3681
- * `base→client` and `base→vendor` into changed regions, sort and coalesce overlapping
3682
- * regions, then emit stable runs verbatim and decide each changed region as above.
3683
- */
3684
3654
  const threeWayMerge = (base, client, vendor) => {
3685
3655
  const regions = diff3Regions(base.split("\n"), client.split("\n"), vendor.split("\n"));
3686
3656
  const out = [];
@@ -3756,12 +3726,6 @@ const diff3Regions = (base, client, vendor) => {
3756
3726
  emitStable(base.length);
3757
3727
  return regions;
3758
3728
  };
3759
- /**
3760
- * The slice of `sideLines` that the region `[regionBaseStart, regionBaseEnd)` maps to
3761
- * on one side. A side may have several hunks in the group (or none). Lines the side
3762
- * left untouched map 1:1 to the base, so the span is widened from the side's own
3763
- * hunks by the base-range delta before/after them — exactly node-diff3's bounds math.
3764
- */
3765
3729
  const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
3766
3730
  const own = group.filter((h) => h.side === side);
3767
3731
  let sideStart = Number.POSITIVE_INFINITY;
@@ -3778,11 +3742,6 @@ const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) =>
3778
3742
  const end = sideEnd + (regionBaseEnd - baseEnd);
3779
3743
  return sideLines.slice(start, end);
3780
3744
  };
3781
- /**
3782
- * The maximal runs where `other` diverges from `base`, derived from the LCS of the
3783
- * two line sequences. Each match anchors a stable point; the gaps between successive
3784
- * anchors (and the head/tail before the first / after the last) are the diffs.
3785
- */
3786
3745
  const diffRegions = (base, other) => {
3787
3746
  const matches = lcsMatches(base, other);
3788
3747
  const diffs = [];
@@ -3806,12 +3765,6 @@ const diffRegions = (base, other) => {
3806
3765
  });
3807
3766
  return diffs;
3808
3767
  };
3809
- /**
3810
- * Longest common subsequence of two line arrays, returned as ascending
3811
- * `[baseIndex, otherIndex]` anchor pairs. Classic O(n·m) DP over a flat Int32Array
3812
- * (typed-array reads are never `undefined`, so the table needs no bounds dance),
3813
- * then a forward walk reconstructs the matched pairs.
3814
- */
3815
3768
  const lcsMatches = (base, other) => {
3816
3769
  const n = base.length;
3817
3770
  const m = other.length;
@@ -3832,45 +3785,13 @@ const lcsMatches = (base, other) => {
3832
3785
  };
3833
3786
  //#endregion
3834
3787
  //#region src/update/engine.ts
3835
- /** The managed skills root — the only multi-file unit the cohesion grouping spans. */
3836
3788
  const SKILLS_PREFIX = join(".claude", "skills");
3837
- /**
3838
- * Cohesion key for orphan prune/adopt grouping (F8). A multi-file skill must be
3839
- * adopted or pruned **as a whole** — never split into a half-skill on disk. So every
3840
- * file under `.claude/skills/<name>/` groups by that skill dir; a client edit to ANY
3841
- * file in it keeps the WHOLE skill (its untouched siblings are kept too, not pruned).
3842
- * Every other path (single-file commands/hooks/agents, the schema, agents.md) is its
3843
- * own singleton group, so non-skill orphans keep the simple per-file behavior.
3844
- */
3845
3789
  const cohesionKey = (relPath) => {
3846
3790
  const prefix = SKILLS_PREFIX + sep;
3847
3791
  if (!relPath.startsWith(prefix)) return relPath;
3848
3792
  const name = relPath.slice(prefix.length).split(sep)[0];
3849
3793
  return name ? join(SKILLS_PREFIX, name) : relPath;
3850
3794
  };
3851
- /**
3852
- * The diff engine (ADR 0005, D6). Walks the union of `baseline ∪ vendor-new` paths
3853
- * and decides each one — uniformly across skills, commands, hooks, and agents,
3854
- * because the engine's universe is exactly the materialized vendor tree (the
3855
- * special-cased `settings.json` / `.gitignore` / `harness.config.yml` are NOT in
3856
- * the baseline, so they never reach here — `runReconcile` handles them separately).
3857
- *
3858
- * Per path, keyed on baseline/catalog presence:
3859
- *
3860
- * - **in both** → 3-way merge against the client's on-disk file (markers on overlap,
3861
- * #49); a deleted client file is restored from the catalog.
3862
- * - **only in the catalog** → a new vendor file: add it, or — when a client file
3863
- * already sits there with no baseline to merge against — resolve the collision
3864
- * whole-file by `onConflict` (D5, default vendor).
3865
- * - **only in the baseline** → an orphan removed from the catalog. The prune-or-adopt
3866
- * choice is made **per cohesion group** (F8): `prune` if the client never touched
3867
- * any file in the group, else `adopt` the whole group (keep on disk, drop from the
3868
- * baseline). An already-deleted orphan is a no-op.
3869
- *
3870
- * Pure planner: it reads the client tree through the injected `readClient` but
3871
- * writes nothing — `runReconcile` applies the returned actions. Actions come back in
3872
- * sorted-path order so the report buckets read deterministically.
3873
- */
3874
3795
  const runEngine = async (inputs) => {
3875
3796
  const { baseline, readClient, onConflict } = inputs;
3876
3797
  const vendorNew = new Map(inputs.vendorNew.map((file) => [file.relPath, file]));
@@ -3901,7 +3822,6 @@ const runEngine = async (inputs) => {
3901
3822
  return action;
3902
3823
  });
3903
3824
  };
3904
- /** Decide a path the new catalog still ships (`vendor` defined). */
3905
3825
  const decideManaged = async (relPath, base, vendor, client, onConflict) => {
3906
3826
  const { content, executable } = vendor;
3907
3827
  if (base === void 0) {
@@ -3944,7 +3864,6 @@ const decideManaged = async (relPath, base, vendor, client, onConflict) => {
3944
3864
  conflicted: merge.conflicted
3945
3865
  };
3946
3866
  };
3947
- /** The action for one orphan, given its group's adopt-vs-prune decision. */
3948
3867
  const orphanAction = (relPath, client, adopts) => {
3949
3868
  if (client === null) return {
3950
3869
  relPath,
@@ -3958,7 +3877,6 @@ const orphanAction = (relPath, client, adopts) => {
3958
3877
  kind: "prune"
3959
3878
  };
3960
3879
  };
3961
- /** Group items by a key, preserving insertion order within each group. */
3962
3880
  const groupBy = (items, key) => {
3963
3881
  const groups = /* @__PURE__ */ new Map();
3964
3882
  for (const item of items) {
@@ -3971,35 +3889,10 @@ const groupBy = (items, key) => {
3971
3889
  };
3972
3890
  //#endregion
3973
3891
  //#region src/install/asset-frontmatter.ts
3974
- /** Leading `---\n … \n---` block, with the fences captured so we can rebuild it. */
3975
3892
  const FRONTMATTER_BLOCK = /^(---\n)([\s\S]*?)(\n---)/;
3976
- /** `{{ vendor_version }}` placeholder (whitespace-tolerant, like `renderTemplate`). */
3977
3893
  const VENDOR_VERSION = /\{\{\s*vendor_version\s*\}\}/g;
3978
- /** The first `description:` line within a frontmatter block. */
3979
3894
  const DESCRIPTION_LINE = /^(description:[ \t]*)(.*)$/m;
3980
- /** A YAML block-scalar header — just `|` or `>`, an optional chomping (`+`/`-`)
3981
- * and/or indent digit, and nothing else on the line. Its value lives on the
3982
- * following lines, so the prefix can't be inlined. Matched precisely so a prose
3983
- * description that merely starts with `>` (e.g. `> 50% faster`) is still
3984
- * prefixed, not mistaken for a block scalar. The catalog uses single-line
3985
- * descriptions; this guard keeps a future multi-line one from being mangled. */
3986
3895
  const BLOCK_SCALAR = /^[|>][+-]?\d*$/;
3987
- /**
3988
- * Materialize-time transform for the frontmatter of installed agents, skills and
3989
- * commands. Two jobs, both scoped to the **leading frontmatter block** so a `{{`
3990
- * in a skill body (an example, a code fence) is never touched and never throws:
3991
- *
3992
- * 1. substitute `{{vendor_version}}` with the real catalog version (#116) — the
3993
- * per-asset version is a placeholder filled at install, not a frozen literal
3994
- * that drifts behind `catalog/VERSION`;
3995
- * 2. prepend {@link VENDOR_DESCRIPTION_PREFIX} to the `description:` value (#117).
3996
- *
3997
- * Files without a frontmatter block (a bundled skill resource like `reference.md`,
3998
- * the `fit-assessment` doc) pass through unchanged. The description prefix is
3999
- * idempotent — a value already carrying the prefix is left as-is — so a re-render
4000
- * (repair/update materializes the same tree) never double-prefixes. An empty or
4001
- * block-scalar (`|` / `>`) description value is left untouched rather than mangled.
4002
- */
4003
3896
  const materializeAssetFrontmatter = (content, opts) => {
4004
3897
  const prefix = opts.descriptionPrefix ?? "🍋";
4005
3898
  const match = FRONTMATTER_BLOCK.exec(content);
@@ -4013,21 +3906,14 @@ const materializeAssetFrontmatter = (content, opts) => {
4013
3906
  };
4014
3907
  //#endregion
4015
3908
  //#region src/install/materialize.ts
4016
- /**
4017
- * The core agent instances installed in every build (decisions #10/#29). Each
4018
- * sub-agent carries a `{{SKILLS}}` marker the installer fills from the resolved
4019
- * skill set; the Orchestrator (a hat) weaves its skills into prose and has no
4020
- * marker, so its render is a pass-through. The Architect is always installed but
4021
- * invoked on-demand (decision #44).
4022
- */
4023
3909
  const CORE_AGENTS = [
4024
3910
  "orchestrator",
4025
3911
  "spec-author",
4026
3912
  "implementer",
4027
3913
  "reviewer",
4028
- "architect"
3914
+ "architect",
3915
+ "ui-designer"
4029
3916
  ];
4030
- /** Read a template and render its `{{vars}}` to a `VendorFile` at `relPath`. */
4031
3917
  const renderFile = async (templatePath, relPath, vars) => {
4032
3918
  return {
4033
3919
  relPath,
@@ -4035,34 +3921,15 @@ const renderFile = async (templatePath, relPath, vars) => {
4035
3921
  executable: false
4036
3922
  };
4037
3923
  };
4038
- /** Read a vendor file verbatim into a `VendorFile` at `relPath`. */
4039
3924
  const copyFileEntry = async (srcPath, relPath, executable = false) => ({
4040
3925
  relPath,
4041
3926
  content: await readFile(srcPath, "utf8"),
4042
3927
  executable
4043
3928
  });
4044
- /**
4045
- * Apply the asset-frontmatter transform to a materialized file: fill the
4046
- * `{{vendor_version}}` placeholder and 🍋-prefix the `description` (#116/#117).
4047
- * A no-op for files without a frontmatter block.
4048
- */
4049
3929
  const withAssetFrontmatter = (file, vendorVersion) => ({
4050
3930
  ...file,
4051
3931
  content: materializeAssetFrontmatter(file.content, { vendorVersion })
4052
3932
  });
4053
- /**
4054
- * The full set of baseline-eligible vendor files for an install context, in memory
4055
- * (decision D6/ADR 0005). This is the **single definition** of "the managed vendor
4056
- * tree": `install` writes this list to disk and mirrors it into the baseline, and
4057
- * `update` re-runs it against the new catalog to get `vendor-new`. The list is
4058
- * exactly the **plain-merge** and **membership** categories of the diff engine —
4059
- * the files whose presence in the baseline marks them vendor-owned.
4060
- *
4061
- * It deliberately excludes the special-cased artifacts that are NOT pure vendor
4062
- * content: `.claude/settings.json` (hooks-block merge), the `.gitignore` managed
4063
- * block, `harness.config.yml` (client-owned), and the forward-only state scaffold.
4064
- * Those are handled directly by the installer and the update engine's handlers.
4065
- */
4066
3933
  const materializeVendorFiles = async (ctx) => {
4067
3934
  const { vendorRoot, target, vars, skills } = ctx;
4068
3935
  const templateRoot = join(vendorRoot, "templates", target);
@@ -4100,16 +3967,6 @@ const materializeVendorFiles = async (ctx) => {
4100
3967
  };
4101
3968
  //#endregion
4102
3969
  //#region src/install/gitignore.ts
4103
- /**
4104
- * The managed `.gitignore` block — vendor authority is restricted to the lines
4105
- * between these markers (the per-dev state + local rollback snapshots that must
4106
- * never be committed). Shared by `install` and `update`/`repair`: all ensure the
4107
- * block is present and current without disturbing the client's own ignore rules.
4108
- *
4109
- * Note `.claude/.harness/baseline/` is deliberately NOT here — the baseline tree is
4110
- * committed (ADR 0005 D2) so `update` is reproducible by any teammate. Only the
4111
- * `snapshots/` sibling is local/gitignored (#48).
4112
- */
4113
3970
  const GITIGNORE_BEGIN = "# BEGIN lemony";
4114
3971
  const GITIGNORE_END = "# END lemony";
4115
3972
  const GITIGNORE_BLOCK = [
@@ -4125,14 +3982,6 @@ const GITIGNORE_BLOCK = [
4125
3982
  ".claude/.harness/snapshots/",
4126
3983
  GITIGNORE_END
4127
3984
  ].join("\n");
4128
- /**
4129
- * Ensure the managed block is present AND current in `.gitignore`. A fresh repo gets
4130
- * the block appended; a repo that already carries it has the block **reconciled** to
4131
- * the canonical content (so a newly-managed ignore — like `snapshots/` added in P7 —
4132
- * lands on the next `update`/`repair` of an already-installed repo, not only on fresh
4133
- * installs). Client lines outside the markers are untouched. Returns the path when it
4134
- * wrote, or null when the block was already byte-identical (idempotent).
4135
- */
4136
3985
  const appendGitignoreBlock = async (repoRoot) => {
4137
3986
  const gitignorePath = join(repoRoot, ".gitignore");
4138
3987
  const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
@@ -4147,19 +3996,6 @@ const appendGitignoreBlock = async (repoRoot) => {
4147
3996
  await writeFile(gitignorePath, `${current}${current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n"}${GITIGNORE_BLOCK}\n`);
4148
3997
  return ".gitignore";
4149
3998
  };
4150
- /**
4151
- * Remove the managed `.gitignore` block (symmetric to `appendGitignoreBlock`).
4152
- * Strips the lines from the BEGIN sentinel through the END sentinel (inclusive),
4153
- * leaving every client line outside the block untouched. Returns the relative path
4154
- * when the block was found and removed, else null (no block present / file absent).
4155
- * If stripping the block empties the file, the now-blank `.gitignore` is removed.
4156
- *
4157
- * The trailer is normalized to a single final newline (the inverse of the blank-line
4158
- * separator `appendGitignoreBlock` inserts). This is content-lossless — no client
4159
- * ignore line is ever dropped — but it is not byte-for-byte: a client file that had
4160
- * no final newline, or trailing blank lines, comes back with exactly one. That EOF
4161
- * whitespace carries no `.gitignore` meaning, so the normalization is intentional.
4162
- */
4163
3999
  const removeGitignoreBlock = async (repoRoot) => {
4164
4000
  const relPath = ".gitignore";
4165
4001
  const gitignorePath = join(repoRoot, relPath);
@@ -4178,17 +4014,6 @@ const removeGitignoreBlock = async (repoRoot) => {
4178
4014
  };
4179
4015
  //#endregion
4180
4016
  //#region src/install/merge-settings.ts
4181
- /**
4182
- * Replace (or add) the `hooks` block in `destPath` with the one declared in
4183
- * `templatePath`. Existing keys outside `hooks` are preserved verbatim;
4184
- * `hooks` is replaced **in toto** so removing a stale entry in a vendor update
4185
- * actually removes it.
4186
- *
4187
- * Creates the file (and parent dirs) when missing. Returns the set of custom
4188
- * command paths the merge wiped so the CLI can print a breadcrumb pointing
4189
- * users at `.claude/settings.local.json` (the supported escape hatch for
4190
- * client-side hook customization).
4191
- */
4192
4017
  const mergeSettingsHooks = async (templatePath, destPath) => {
4193
4018
  const templateRaw = await readFile(templatePath, "utf8");
4194
4019
  const template = JSON.parse(templateRaw);
@@ -4211,15 +4036,6 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4211
4036
  await writeFile(destPath, `${JSON.stringify(merged, null, 2)}\n`);
4212
4037
  return { lostCommands };
4213
4038
  };
4214
- /**
4215
- * Remove the vendor `hooks` block from `.claude/settings.json` (symmetric to
4216
- * `mergeSettingsHooks`). Deletes only the `hooks` key — every client-owned key is
4217
- * preserved. If `hooks` was the file's only key, the now-empty settings.json is
4218
- * removed (install effectively owned the whole file). Returns what happened:
4219
- * `'absent'` (no file, empty file, or no `hooks` key), `'stripped'` (hooks removed,
4220
- * file kept with the client's keys), `'removed-file'` (hooks removed, empty file
4221
- * deleted).
4222
- */
4223
4039
  const removeSettingsHooks = async (settingsPath) => {
4224
4040
  if (!await pathExists(settingsPath)) return "absent";
4225
4041
  const raw = await readFile(settingsPath, "utf8");
@@ -4239,13 +4055,6 @@ const removeSettingsHooks = async (settingsPath) => {
4239
4055
  await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
4240
4056
  return "stripped";
4241
4057
  };
4242
- /**
4243
- * Walk a hooks block and collect every `command` string under any event type.
4244
- * The Claude Code hooks shape is `{ <EventType>: [{ matcher?, hooks: [{ type:
4245
- * 'command', command: string }] }] }`. Anything that doesn't fit (the user
4246
- * had it the "wrong" way) is skipped silently — we're computing a set diff,
4247
- * not validating shape.
4248
- */
4249
4058
  const collectCommands = (hooks) => {
4250
4059
  const result = /* @__PURE__ */ new Set();
4251
4060
  if (!hooks || typeof hooks !== "object") return result;
@@ -4262,11 +4071,6 @@ const collectCommands = (hooks) => {
4262
4071
  }
4263
4072
  return result;
4264
4073
  };
4265
- /**
4266
- * Set diff between the commands found in `existingHooks` and those in
4267
- * `templateHooks`. The result is sorted so the breadcrumb output is stable
4268
- * across runs (otherwise iteration order would vary by JSON parse).
4269
- */
4270
4074
  const diffLostCommands = (existingHooks, templateHooks) => {
4271
4075
  const existing = collectCommands(existingHooks);
4272
4076
  const template = collectCommands(templateHooks);
@@ -4276,18 +4080,6 @@ const diffLostCommands = (existingHooks, templateHooks) => {
4276
4080
  };
4277
4081
  //#endregion
4278
4082
  //#region src/install/resync.ts
4279
- /**
4280
- * Re-sync the two **special-cased** artifacts that sit OUTSIDE the 3-way baseline:
4281
- * the `.claude/settings.json` hooks block (vendor authority is the `hooks` key only)
4282
- * and the managed `.gitignore` block. Both preserve every client-owned line around
4283
- * them. Shared verbatim by `install` (fresh) and `reconcile` (update/repair)
4284
- * — the one sequence that was duplicated between them (issue #81/A3): same template
4285
- * paths, same order, same `lostCommands` surfacing. install also reports the
4286
- * `.gitignore` path it wrote (for `filesWritten`); reconcile ignores it.
4287
- *
4288
- * `templateRoot` is `<vendorRoot>/templates/<target>` — the caller resolves it (it
4289
- * already holds `target` from the config or the install options).
4290
- */
4291
4083
  const resyncSpecialCased = async (repoRoot, templateRoot) => {
4292
4084
  const { lostCommands } = await mergeSettingsHooks(join(templateRoot, ".claude", "settings.json.tpl"), join(repoRoot, ".claude", "settings.json"));
4293
4085
  return {
@@ -4298,35 +4090,19 @@ const resyncSpecialCased = async (repoRoot, templateRoot) => {
4298
4090
  //#endregion
4299
4091
  //#region src/install/install.ts
4300
4092
  const CLAUDE_MD_FILENAME = "CLAUDE.md";
4301
- /** Non-interactive default: vendor wins every collision (the non-TTY contract, D8). */
4302
4093
  const VENDOR_WINS = async () => "vendor";
4303
4094
  const OPERATIVE_TARGET = "claude-code";
4304
- /** Shared, committed state subdirs — scaffolded empty, kept in git via `.gitkeep`. */
4305
4095
  const COMMITTED_STATE_DIRS = ["tasks", "metrics"];
4306
- /** Write a `VendorFile` to disk via the shared managed-write (parent dirs + exec mode). */
4307
4096
  const writeVendorFile = async (repoRoot, file) => {
4308
4097
  await writeManaged(repoRoot, file.relPath, file.content, file.executable);
4309
4098
  return file.relPath;
4310
4099
  };
4311
- /** Write `content` to a repo-relative path, creating parent dirs. Returns the path. */
4312
4100
  const writeOut = async (repoRoot, relPath, content) => {
4313
4101
  const dest = join(repoRoot, relPath);
4314
4102
  await mkdir(dirname(dest), { recursive: true });
4315
4103
  await writeFile(dest, content);
4316
4104
  return relPath;
4317
4105
  };
4318
- /**
4319
- * Install the harness into a client repo — **fresh-only** (ADR 0005, decision D3).
4320
- *
4321
- * Refuses if `harness.config.yml` already exists ("already installed; use
4322
- * `update`"), undoing P6's idempotent-reinstall stopgap now that `update`/`repair`
4323
- * own re-sync and config migration. A fresh install: scans capabilities, renders
4324
- * the entry protocol + agents + skills, writes the **baseline tree** (a verbatim
4325
- * copy of every materialized vendor file at `.claude/.harness/baseline/<version>/`,
4326
- * the merge base for the next `update`), scaffolds `.claude/state/`, appends the
4327
- * managed `.gitignore` block, merges the `settings.json` hooks block, writes
4328
- * `harness.config.yml`, and reconciles the `harness:*` labels.
4329
- */
4330
4106
  const runInstall = async (options) => {
4331
4107
  const { repoRoot, vendorRoot } = options;
4332
4108
  if (await pathExists(join(repoRoot, "harness.config.yml"))) throw new Error(`Lemony is already installed here (${HARNESS_CONFIG_FILENAME} exists). Run \`lemony update\` to change the pinned version.`);
@@ -4437,25 +4213,12 @@ const runInstall = async (options) => {
4437
4213
  latentCapabilities: latent
4438
4214
  };
4439
4215
  };
4440
- /**
4441
- * The client files about to be overwritten by a `pick-vendor` collision, captured as a
4442
- * `VendorFile[]` for the rollback snapshot. Called BEFORE any vendor write — the files
4443
- * still hold the client's hand-made copy here. The on-disk read is the shared
4444
- * `collectWorkingFiles` (reconcile snapshots its whole baseline the same way); install
4445
- * just narrows the path set to the losing collisions.
4446
- */
4447
4216
  const collectLosers = (repoRoot, actions) => {
4448
4217
  return collectWorkingFiles(repoRoot, actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath));
4449
4218
  };
4450
4219
  //#endregion
4451
4220
  //#region src/install/resolve-collision.ts
4452
4221
  const toLines = (text) => text.replace(/\n$/, "").split("\n");
4453
- /**
4454
- * Render a whole-file pick as a unified-ish diff: the client's current copy as removed
4455
- * (`-`) lines, the harness vendor copy as added (`+`) lines. There is no merge base at
4456
- * install (decision D8), so a line-level 3-way diff is neither possible nor meaningful
4457
- * — the choice is whole-file, and seeing both sides verbatim is what the user needs.
4458
- */
4459
4222
  const renderDiff = (relPath, vendorContent, clientContent) => {
4460
4223
  return [
4461
4224
  `--- ${relPath} (your current copy)`,
@@ -4464,14 +4227,6 @@ const renderDiff = (relPath, vendorContent, clientContent) => {
4464
4227
  ...toLines(vendorContent).map((line) => `+ ${line}`)
4465
4228
  ].join("\n");
4466
4229
  };
4467
- /**
4468
- * Build the interactive per-collision resolver the CLI injects into `runInstall` when
4469
- * stdin is a TTY (decision D8). For each pre-existing file that collides with a vendor
4470
- * file, it offers `[keep-vendor (recommended)] / [keep-client] / [diff]`, looping on a
4471
- * `[diff]` request or an unrecognized answer. The empty answer accepts the recommended
4472
- * default (vendor). Non-TTY installs never build this — `runInstall` falls back to its
4473
- * vendor-wins default and reports the collisions instead.
4474
- */
4475
4230
  const createInteractiveCollisionResolver = (deps) => {
4476
4231
  return async (relPath, vendorContent, clientContent) => {
4477
4232
  deps.print("");
@@ -4491,33 +4246,6 @@ const createInteractiveCollisionResolver = (deps) => {
4491
4246
  };
4492
4247
  //#endregion
4493
4248
  //#region src/update/reconcile.ts
4494
- /**
4495
- * The shared baseline-aware reconcile core (ADR 0005, D6). `update` and `repair`
4496
- * are the same operation — "materialize the managed vendor tree for `toVersion`,
4497
- * 3-way merge it against the committed baseline, apply, re-sync the special-cased
4498
- * artifacts, refresh the baseline, bump the config" — differing only in WHAT version
4499
- * they materialize and WHICH config value they bump. Those differences are the
4500
- * `ReconcileInputs`; everything else lives here once.
4501
- *
4502
- * The spine:
4503
- *
4504
- * 1. Read the client config (target/slug/snapshot-retention/from-version) and the
4505
- * on-disk baseline (the merge base; absent → pick-one degrade, D2).
4506
- * 2. Materialize the catalog's managed tree for `toVersion` (`vendor-new`).
4507
- * 3. **Refuse** if any managed file still carries a residual conflict marker (#49),
4508
- * and refuse if a managed path is reachable only through a symlink (P7/S2).
4509
- * 4. Run the pure diff engine, then — unless `dryRun` — apply its actions: write
4510
- * merges/adds/restores, delete prunes, leave adoptions and client picks in place.
4511
- * 5. Re-sync the special-cased artifacts (the `settings.json` hooks block, the
4512
- * `.gitignore` block) and the `harness:*` labels.
4513
- * 6. Refresh the baseline to `toVersion` (adopted orphans fall out) and apply the
4514
- * config value-bumps last (the commit point — a crash leaves the old state and a
4515
- * safe, idempotent retry).
4516
- *
4517
- * `dryRun` short-circuits after the engine with a faithful report and zero writes —
4518
- * the guards in step 3 still run (they are read-only and the preview should reflect
4519
- * a refusal a real run would hit).
4520
- */
4521
4249
  const runReconcile = async (inputs) => {
4522
4250
  const { repoRoot, vendorRoot, toVersion, configBumps } = inputs;
4523
4251
  const dryRun = inputs.dryRun ?? false;
@@ -4606,7 +4334,6 @@ const applyActions = async (repoRoot, actions) => {
4606
4334
  }));
4607
4335
  await pruneEmptyDirs(repoRoot, actions.filter((action) => action.kind === "prune").map((action) => action.relPath));
4608
4336
  };
4609
- /** Bucket the engine actions into the report's path lists. */
4610
4337
  const summarize = (actions) => {
4611
4338
  const conflictedFiles = [];
4612
4339
  const mergedFiles = [];
@@ -4653,13 +4380,6 @@ const summarize = (actions) => {
4653
4380
  };
4654
4381
  //#endregion
4655
4382
  //#region src/update/update.ts
4656
- /**
4657
- * Update a harnessed repo to the catalog at `vendorRoot` (ADR 0005). A thin wrapper
4658
- * over the shared `runReconcile` core: it resolves the **target version** (the new
4659
- * catalog's `VERSION`) and the bump (`vendor_version`), and lets the core do the
4660
- * materialize → 3-way merge → snapshot → apply → re-sync → refresh-baseline → bump
4661
- * dance. `repair` reuses this wrapper at the same version.
4662
- */
4663
4383
  const runUpdate = async (options) => {
4664
4384
  const { repoRoot, vendorRoot } = options;
4665
4385
  const onConflict = options.onConflict ?? "vendor";
@@ -4683,16 +4403,6 @@ const runUpdate = async (options) => {
4683
4403
  };
4684
4404
  //#endregion
4685
4405
  //#region src/repair/repair.ts
4686
- /**
4687
- * Re-sync a harnessed repo at its **pinned** version (ADR 0005 D3). Repair is
4688
- * `update` with `vendor-old == vendor-new`: the 3-way merge collapses to "restore
4689
- * missing vendor files, leave client edits", and the special-cased artifacts
4690
- * (settings hooks, gitignore block, `harness:*` labels) are re-synced — without
4691
- * clobbering a present, client-edited file (the baseline 3-way detects the edit).
4692
- *
4693
- * It refuses when the catalog is a DIFFERENT version than the pin: that is a version
4694
- * change, which is `update`'s job (a true 3-way + pin bump), not repair's.
4695
- */
4696
4406
  const runRepair = async (options) => {
4697
4407
  const { repoRoot, vendorRoot } = options;
4698
4408
  const config = await readHarnessConfig(repoRoot);
@@ -4707,35 +4417,9 @@ const runRepair = async (options) => {
4707
4417
  };
4708
4418
  //#endregion
4709
4419
  //#region src/uninstall/uninstall.ts
4710
- /** The harness-private tree (baseline + snapshots) — removed wholesale. */
4711
4420
  const HARNESS_DIR = join(".claude", ".harness");
4712
- /** Vendor authority over settings is the `hooks` block only (preserve client keys). */
4713
4421
  const SETTINGS_RELPATH = join(".claude", "settings.json");
4714
- /** All of `docs/` is preserved (D7) — even the vendor `playbooks/README.md`. */
4715
4422
  const DOCS_PREFIX = `docs${sep}`;
4716
- /**
4717
- * `uninstall` — the inverse of install over **vendor-managed** files (ADR 0005 D7).
4718
- *
4719
- * The baseline tree IS the vendor-origin registry (D2): _presence in the baseline_
4720
- * is what marks a file vendor-managed, uniformly across `.md`/`.sh`/`.json`. So
4721
- * uninstall enumerates the baseline keys and removes them — except anything under
4722
- * `docs/` (preserved: a harmless leftover beats touching client docs). Files the
4723
- * client adopted-as-client already fell out of the baseline, so they survive.
4724
- *
4725
- * Beyond the registry it strips the three special-cased artifacts symmetric to
4726
- * install (the `settings.json` hooks block — client keys preserved; the `.gitignore`
4727
- * managed block) and removes the install marker `harness.config.yml` plus the whole
4728
- * `.claude/.harness/` tree (baseline + snapshots).
4729
- *
4730
- * **Preserved**: `CLAUDE.md`, `CONTEXT.md`, all of `docs/`, `.claude/state/**`,
4731
- * `events.jsonl`, adopted-as-client skills. **Labels** are left on the remote unless
4732
- * `--labels` is given (they may be attached to live issues).
4733
- *
4734
- * **Refuses** when not installed (no `harness.config.yml`) and when there is no
4735
- * baseline registry — without it uninstall cannot tell a vendor file from a client
4736
- * one, and a half-removal (config gone, orphan vendor files left) is worse than a
4737
- * clear refusal (HITL call, S3 PR-B). `repair` restores a missing baseline.
4738
- */
4739
4423
  const runUninstall = async (options) => {
4740
4424
  const { repoRoot } = options;
4741
4425
  if (!await pathExists(join(repoRoot, "harness.config.yml"))) throw new Error(`Lemony is not installed here (${HARNESS_CONFIG_FILENAME} not found).`);
@@ -4770,18 +4454,6 @@ const runUninstall = async (options) => {
4770
4454
  };
4771
4455
  //#endregion
4772
4456
  //#region src/rollback/rollback.ts
4773
- /**
4774
- * Roll a harnessed repo back to a pre-update snapshot (#48, ADR 0005 D7), offline.
4775
- * The two-part bundle is restored together: the `working` tree goes back to disk
4776
- * verbatim (with exec modes), and the pristine `baseline` half is re-established as
4777
- * the active merge base — so the next `update` after a rollback is still a true
4778
- * 3-way merge, not a silent pick-one degrade. The pin reverts to the target version.
4779
- *
4780
- * Files added by the update being undone (managed now, absent from the snapshot) are
4781
- * removed so the tree truly reverts. A git-dirty guard refuses if uncommitted changes
4782
- * to a touched managed file would be lost — anything committed is git-recoverable, so
4783
- * only uncommitted work is at risk; `--force` overrides.
4784
- */
4785
4457
  const runRollback = async (options) => {
4786
4458
  const { repoRoot, force } = options;
4787
4459
  const snapshots = await listSnapshots(repoRoot);
@@ -4817,15 +4489,6 @@ const runRollback = async (options) => {
4817
4489
  removedFiles: removePaths
4818
4490
  };
4819
4491
  };
4820
- /**
4821
- * The subset of `touched` paths that `git status` reports as changed in the working
4822
- * tree. Uses `--porcelain -z`: NUL-delimited records with **no path quoting** (so a
4823
- * managed file with a space/special char isn't silently missed — the v1 default
4824
- * quotes those, which would defeat the guard). Each record is `XY <path>`; a rename
4825
- * or copy (`R`/`C`) emits the original path as its OWN bare NUL field right after, so
4826
- * both ends are counted. A non-git repo or a git error degrades to "nothing dirty"
4827
- * (best-effort safety, never a hard block on an un-versioned repo).
4828
- */
4829
4492
  const dirtyManagedPaths = async (repoRoot, run, touched) => {
4830
4493
  const result = await run("git", [
4831
4494
  "-C",
@@ -4854,30 +4517,12 @@ const dirtyManagedPaths = async (repoRoot, run, touched) => {
4854
4517
  //#endregion
4855
4518
  //#region src/install/resolve-slug.ts
4856
4519
  const SLUG_PATTERN = TASK_STORAGE_REPO_PATTERN;
4857
- /**
4858
- * Validate a slug at flag-parse time. Throws a friendly message on every
4859
- * rejection reason so the CLI surfaces the cause directly instead of a stack
4860
- * trace. The same regex feeds the interactive prompt; placeholder is rejected
4861
- * separately so the error message can be specific.
4862
- */
4863
4520
  const validateSlugOrThrow = (slug) => {
4864
4521
  const trimmed = slug.trim();
4865
4522
  if (trimmed === "") throw new Error("task_storage.repo cannot be empty.");
4866
4523
  if (trimmed === "OWNER/REPO") throw new Error(`task_storage.repo cannot be the OWNER/REPO placeholder — set a real slug.`);
4867
4524
  if (!SLUG_PATTERN.test(trimmed)) throw new Error(`task_storage.repo must be in owner/name format (got "${slug}").`);
4868
4525
  };
4869
- /**
4870
- * Resolve `task_storage.repo` for an install. Order of precedence:
4871
- *
4872
- * 1. `--task-storage-repo=<slug>` flag (explicit, validated by caller).
4873
- * 2. The slug `scanRepo` parsed from the local git origin remote.
4874
- * 3. TTY interactive menu — manual entry, gh-create-and-wire, or skip.
4875
- * 4. Non-TTY → placeholder + warning (the emit-gate refuses placeholder
4876
- * events later, so polluted telemetry can't escape).
4877
- *
4878
- * The whole interactive flow lives behind `deps` so tests don't need to mock
4879
- * TTY or shell out to `gh`.
4880
- */
4881
4526
  const resolveTaskStorageRepo = async (input) => {
4882
4527
  const { scannedSlug, flagSlug, deps } = input;
4883
4528
  if (flagSlug) return {
@@ -4935,13 +4580,6 @@ const promptManualSlug = async (deps) => {
4935
4580
  }
4936
4581
  }
4937
4582
  };
4938
- /**
4939
- * Run `gh repo create <slug> --(private|public) --source=. --remote=origin`.
4940
- * Returns the resolved result on success, or `null` to signal the caller to
4941
- * present the retry menu. gh failures (gh missing, not authed, name taken,
4942
- * etc.) all flow through this same path — the stderr we forward names the
4943
- * specific cause.
4944
- */
4945
4583
  const tryGhCreate = async (deps) => {
4946
4584
  const slug = await promptValidSlug(deps, "Full slug (owner/name): ");
4947
4585
  const visibility = await promptVisibility(deps);
@@ -5015,11 +4653,6 @@ const readHarnessVersion = async () => {
5015
4653
  if (!parsed.version) throw new Error(`package.json at ${PACKAGE_JSON_PATH} has no version.`);
5016
4654
  return parsed.version;
5017
4655
  };
5018
- /**
5019
- * Wrap `execFile` as the `{code, stdout, stderr}` shell-out the resolver, label
5020
- * sync, status, and doctor all share. A missing binary (ENOENT) maps to exit 127
5021
- * so callers can show a "not found" path instead of crashing.
5022
- */
5023
4656
  const makeRunCommand = () => async (cmd, args) => {
5024
4657
  try {
5025
4658
  const result = await execFileAsync(cmd, args);
@@ -5042,13 +4675,6 @@ const makeRunCommand = () => async (cmd, args) => {
5042
4675
  };
5043
4676
  }
5044
4677
  };
5045
- /**
5046
- * Build the real I/O dependencies the slug resolver needs at the CLI layer.
5047
- * Tests target `resolveTaskStorageRepo` directly with scripted mocks; this
5048
- * builder exists only to wire `readline/promises` + `execFile` for the
5049
- * production path. Returns the deps alongside a `close()` the install command
5050
- * must call so the readline interface releases stdin and the process exits.
5051
- */
5052
4678
  const buildResolveDeps = () => {
5053
4679
  const rl = createInterface({
5054
4680
  input: stdin,
@@ -5066,11 +4692,6 @@ const buildResolveDeps = () => {
5066
4692
  close: () => rl.close()
5067
4693
  };
5068
4694
  };
5069
- /**
5070
- * Current branch + how many commits it trails origin's default branch, using
5071
- * only local refs (no fetch) — the same cheap read `init.sh` does. Either field
5072
- * is null when it can't be determined (no remote HEAD ref, detached, etc.).
5073
- */
5074
4695
  const gitBehind = async (repoRoot) => {
5075
4696
  const run = makeRunCommand();
5076
4697
  const branchResult = await run("git", [
@@ -5164,35 +4785,16 @@ const install = async (args) => {
5164
4785
  });
5165
4786
  } catch {}
5166
4787
  };
5167
- /**
5168
- * Surface the opt-in capabilities the repo could enable but hasn't (issue #136): each
5169
- * line names the convention file to create and what it unlocks. Warn-only — silent when
5170
- * nothing is latent (every gated capability already active, or none applies). The
5171
- * harness never creates these files (decision #8); it only informs.
5172
- */
5173
4788
  const reportLatentCapabilities = (latent) => {
5174
4789
  if (latent.length === 0) return;
5175
4790
  console.log("Opt-in capabilities available (not installed):");
5176
4791
  for (const cap of latent) console.log(` ${cap.trigger} → ${cap.skills.join(", ")}: ${cap.label}.`);
5177
- console.log(" Run /add-capability in Claude Code to activate one — the Architect authors the artifact for you (#8: opt-in, never imposed).");
5178
- };
5179
- /**
5180
- * Nudge the user to add the harness as a devDependency (#107/#113). The hooks and
5181
- * agents resolve the telemetry CLI from the project-local `node_modules/.bin` first;
5182
- * without it they fall back to a global/`npx` binary that an fnm Node switch can drop,
5183
- * silently skipping telemetry. Warn-only — install never installs deps for the user.
5184
- */
4792
+ console.log(" Run /add-capability in Claude Code to activate one — the Architect authors the artifact for you (opt-in, never imposed).");
4793
+ };
5185
4794
  const reportDevDependency = (devDependencyMissing) => {
5186
4795
  if (!devDependencyMissing) return;
5187
4796
  console.log("For reliable telemetry, add the harness as a devDependency: `npm i -D @lemoncode/lemony` — the hooks resolve the CLI from node_modules/.bin first, which survives an fnm/global PATH change.");
5188
4797
  };
5189
- /**
5190
- * Surface the no-baseline collisions reconciliation resolved (decision D8): which
5191
- * hand-made files were kept (client) or overwritten (vendor), and where the displaced
5192
- * client copies were snapshotted. The header names how the pick was made: an explicit
5193
- * `--on-conflict` (`forced`), the interactive TTY prompt, or the non-TTY vendor default
5194
- * — so the suffix never contradicts the per-file lines below it.
5195
- */
5196
4798
  const reportCollisions = (collisions, snapshotVersion, mode) => {
5197
4799
  if (collisions.length === 0) return;
5198
4800
  const vendorWins = collisions.filter((c) => c.winner === "vendor");
@@ -5204,20 +4806,10 @@ const reportCollisions = (collisions, snapshotVersion, mode) => {
5204
4806
  for (const { relPath } of vendorWins) console.log(` used vendor copy: ${relPath}`);
5205
4807
  if (snapshotVersion !== null) console.log(`Your displaced ${vendorWins.length === 1 ? "copy was" : "copies were"} saved — restore with \`lemony rollback --to ${snapshotVersion}\`.`);
5206
4808
  };
5207
- /**
5208
- * Remind the user about an existing `CLAUDE.md` (decision D8, warn-only). The harness
5209
- * never reads or edits it; the user may have guidance there that overlaps the freshly
5210
- * installed agents/skills and should reconcile it by hand.
5211
- */
5212
4809
  const reportClaudeMd = (claudeMdPresent) => {
5213
4810
  if (!claudeMdPresent) return;
5214
4811
  console.log("Found an existing CLAUDE.md — left untouched. Review it for guidance that now overlaps the installed harness (agents.md, .claude/).");
5215
4812
  };
5216
- /**
5217
- * Surface custom hook commands the settings re-merge silently dropped so the user
5218
- * can restore them in `.claude/settings.local.json` (the supported escape hatch —
5219
- * vendor authority is the `hooks` block only). Shared by install and update.
5220
- */
5221
4813
  const reportLostHooks = (lostHookCommands) => {
5222
4814
  if (lostHookCommands.length === 0) return;
5223
4815
  const count = lostHookCommands.length;
@@ -5225,14 +4817,6 @@ const reportLostHooks = (lostHookCommands) => {
5225
4817
  for (const command of lostHookCommands) console.log(` ${command}`);
5226
4818
  console.log("Restore them in .claude/settings.local.json if you want them back.");
5227
4819
  };
5228
- /**
5229
- * `update [--on-conflict=<vendor|client>] [--dry-run] [--allow-downgrade]` — move the
5230
- * install to the catalog version via the shared 3-way reconcile (ADR 0005). `--dry-run`
5231
- * previews the plan (merge/add/prune/adopt counts + per-file picks/adoptions/conflicts)
5232
- * and writes nothing — the read-only refusal guards still run, so the preview reflects a
5233
- * real run. By default `update` refuses a downgrade (installed catalog older than the
5234
- * pin, #172); `--allow-downgrade` forces it.
5235
- */
5236
4820
  const update = async (args) => {
5237
4821
  const repoRoot = cwd();
5238
4822
  const onConflict = parseOnConflict(args);
@@ -5259,11 +4843,6 @@ const update = async (args) => {
5259
4843
  }
5260
4844
  };
5261
4845
  const isOnConflict = (value) => value === "vendor" || value === "client";
5262
- /**
5263
- * Parse + validate the shared `--on-conflict=<vendor|client>` flag (used by `install`
5264
- * and `update`). Returns the policy, `undefined` when absent, and exits 1 on a bad
5265
- * value so a typo fails fast at the boundary instead of silently no-opping.
5266
- */
5267
4846
  const parseOnConflict = (args) => {
5268
4847
  const raw = parseFlag(args, "on-conflict");
5269
4848
  if (raw === void 0) return void 0;
@@ -5273,12 +4852,6 @@ const parseOnConflict = (args) => {
5273
4852
  }
5274
4853
  return raw;
5275
4854
  };
5276
- /**
5277
- * `repair [--dry-run]` — baseline-aware re-sync at the pinned version (ADR 0005 D3):
5278
- * restore missing vendor files + re-sync labels/hooks, never clobbering a client edit.
5279
- * Refuses (pointing at `update`) when the catalog is a different version. `--dry-run`
5280
- * previews the re-sync plan (shared with `update`) and writes nothing.
5281
- */
5282
4855
  const repair = async (args) => {
5283
4856
  const dryRun = args.includes("--dry-run");
5284
4857
  const result = await runRepair({
@@ -5297,12 +4870,6 @@ const repair = async (args) => {
5297
4870
  reportLostHooks(result.lostHookCommands);
5298
4871
  }
5299
4872
  };
5300
- /**
5301
- * `uninstall [--labels]` — remove vendor-managed files (ADR 0005, D7). The inverse
5302
- * of install over the baseline registry; preserves CLAUDE.md/CONTEXT.md/docs/state/
5303
- * events.jsonl/adopted skills. Labels stay on the remote unless `--labels` is given.
5304
- * `runUninstall` refuses when not installed or when no baseline registry exists.
5305
- */
5306
4873
  const uninstall = async (args) => {
5307
4874
  const requestedLabels = args.includes("--labels");
5308
4875
  const result = await runUninstall({
@@ -5321,22 +4888,9 @@ const uninstall = async (args) => {
5321
4888
  console.log(" Preserved: CLAUDE.md, CONTEXT.md, docs/, .claude/state/, events.jsonl, adopted skills.");
5322
4889
  reportLabelDeletion(result.labelDeletion, requestedLabels);
5323
4890
  };
5324
- /** Print the label outcome for `uninstall` — formatting lives in `formatLabelDeletion`. */
5325
4891
  const reportLabelDeletion = (deletion, requested) => {
5326
4892
  for (const line of formatLabelDeletion(deletion, requested)) console.log(line);
5327
4893
  };
5328
- /**
5329
- * `rollback [--to=<version>] [--list] [--cleanup] [--force]` — restore a pre-change
5330
- * snapshot (#48), offline. `--list` prints the retained snapshots, `--cleanup` clears
5331
- * them; both are read/metadata operations that never touch the working tree. With
5332
- * neither, the default restores the most recent snapshot (or `--to=<version>`).
5333
- *
5334
- * Note: EVERY mutating command snapshots before it writes — `update` and `repair`.
5335
- * So the "most recent" snapshot a no-arg rollback restores may be the one a `repair`
5336
- * took (same pinned version), not the last `update`. That rollback is a real revert
5337
- * (the pre-change tree + baseline) but leaves the version unchanged; to undo an
5338
- * `update`, target the prior version with `--to=` (see `--list`).
5339
- */
5340
4894
  const rollback = async (args) => {
5341
4895
  const repoRoot = cwd();
5342
4896
  const requestedModes = [
@@ -5377,30 +4931,18 @@ const rollback = async (args) => {
5377
4931
  console.log(" newest one. To undo an `update`, see `rollback --list` then `rollback --to=<version>`.");
5378
4932
  }
5379
4933
  };
5380
- /**
5381
- * Print the unresolved-conflict block shared by `update` and `repair`
5382
- * (3-way merge writes markers, never blocks — #49). `trailer` is the verb-specific
5383
- * "the next <verb> refuses until they are gone" hint; `repair` passes none.
5384
- */
5385
4934
  const reportConflicts = (conflictedFiles, trailer) => {
5386
4935
  if (conflictedFiles.length === 0) return;
5387
4936
  console.log(`\n${conflictedFiles.length} file(s) need conflict resolution:`);
5388
4937
  for (const relPath of conflictedFiles) console.log(` ${relPath}`);
5389
4938
  if (trailer) console.log(trailer);
5390
4939
  };
5391
- /**
5392
- * Print the adopted-orphans block (`update`/`repair`) — files the engine kept on disk
5393
- * and dropped from the baseline. `label` carries the caller's tense ("adopted" /
5394
- * "would adopt" for a dry run).
5395
- */
5396
4940
  const reportAdoptions = (adoptedFiles, label = "adopted") => {
5397
4941
  for (const relPath of adoptedFiles) console.log(` ${label} ${relPath} (kept your copy; no longer vendor-managed).`);
5398
4942
  };
5399
- /** Print the harness:* label reconciliation outcome — formatting in `formatLabelSync`. */
5400
4943
  const reportLabelSync = (sync) => {
5401
4944
  for (const line of formatLabelSync(sync)) console.log(line);
5402
4945
  };
5403
- /** Print a verb's best-effort label preflight — formatting in `formatLabelSelfHeal`. */
5404
4946
  const reportLabelSelfHeal = (sync) => {
5405
4947
  for (const line of formatLabelSelfHeal(sync)) console.log(line);
5406
4948
  };
@@ -5413,14 +4955,6 @@ const emit = async (args) => {
5413
4955
  });
5414
4956
  console.log(`emitted ${event.type} → ${eventsPath}`);
5415
4957
  };
5416
- /**
5417
- * `telemetry <status|show|flush|disable|enable>` — the human control surface for
5418
- * anonymous telemetry (#232/#227). Two actions stay internal/hook-facing: `send` (the
5419
- * terse fire-and-forget flush `session-close.sh` invokes, #225) and `notice` (the
5420
- * SessionStart disclosure `init.sh` shells out to). `flush` (#227) is the user-facing
5421
- * "force a send now": the same robust engine as `send`, with a verbose human report.
5422
- * Bare `telemetry` defaults to `status`.
5423
- */
5424
4958
  const runTelemetry = async (args) => {
5425
4959
  const action = args[0] ?? "status";
5426
4960
  const repoRoot = cwd();
@@ -5452,7 +4986,6 @@ const runTelemetry = async (args) => {
5452
4986
  default: throw new Error(`Usage: lemony telemetry <status|show|flush|disable|enable>. Unknown action "${action}".`);
5453
4987
  }
5454
4988
  };
5455
- /** `telemetry status` — print the resolved on/off, decision source, and watermark. */
5456
4989
  const telemetryStatus = async (repoRoot) => {
5457
4990
  const report = await buildTelemetryStatus({
5458
4991
  repoRoot,
@@ -5460,16 +4993,10 @@ const telemetryStatus = async (repoRoot) => {
5460
4993
  });
5461
4994
  for (const line of formatTelemetryStatus(report)) console.log(line);
5462
4995
  };
5463
- /** `telemetry show` — dump every local event raw, then its sanitized projection. */
5464
4996
  const telemetryShow = async (repoRoot) => {
5465
4997
  const report = await buildTelemetryShow(repoRoot);
5466
4998
  for (const line of formatTelemetryShow(report)) console.log(line);
5467
4999
  };
5468
- /**
5469
- * `telemetry disable [--purge-local]` — write the per-repo-local opt-out, optionally
5470
- * wiping the local log + cursor, then print the resolved status (precedence honesty:
5471
- * if a higher lever like the env var already controls, the status names it).
5472
- */
5473
5000
  const telemetryDisable = async (repoRoot, args) => {
5474
5001
  await disableLocally(repoRoot);
5475
5002
  const purge = args.includes("--purge-local");
@@ -5481,11 +5008,6 @@ const telemetryDisable = async (repoRoot, args) => {
5481
5008
  env
5482
5009
  }))) console.log(line);
5483
5010
  };
5484
- /**
5485
- * `telemetry enable` — clear the local opt-out. Honest about precedence: clearing the
5486
- * local lever does not force telemetry on if the env var or committed config still
5487
- * disables it, so re-resolve and say so rather than claim "enabled".
5488
- */
5489
5011
  const telemetryEnable = async (repoRoot) => {
5490
5012
  await enableLocally(repoRoot);
5491
5013
  const report = await buildTelemetryStatus({
@@ -5495,11 +5017,6 @@ const telemetryEnable = async (repoRoot) => {
5495
5017
  console.log(report.enabled ? "Telemetry enabled (anonymous) for this checkout." : "Local opt-out cleared, but telemetry is still OFF for another reason:");
5496
5018
  for (const line of formatTelemetryStatus(report)) console.log(line);
5497
5019
  };
5498
- /**
5499
- * `telemetry send` — flush the unsent tail of `events.jsonl` to the ingest Worker
5500
- * (#225). Internal/hook-facing: `session-close.sh` invokes it fire-and-forget; the
5501
- * engine never throws and self-times-out (D4), so this only prints a terse one-liner.
5502
- */
5503
5020
  const telemetrySend = async (repoRoot) => {
5504
5021
  const result = await sendTelemetry({
5505
5022
  repoRoot,
@@ -5507,11 +5024,6 @@ const telemetrySend = async (repoRoot) => {
5507
5024
  });
5508
5025
  console.log(`telemetry ${result.outcome} (${result.segmentsSent} sent, ${result.quarantined} quarantined)`);
5509
5026
  };
5510
- /**
5511
- * `telemetry flush` — the user-facing "force a send now" (#227). Same robust engine as
5512
- * `send` (chunking + cursor policy + quarantine, consent-gated), with a verbose report
5513
- * of what it did, so a human debugging "is my telemetry flowing?" sees the whole picture.
5514
- */
5515
5027
  const telemetryFlush = async (repoRoot) => {
5516
5028
  const result = await sendTelemetry({
5517
5029
  repoRoot,
@@ -5539,14 +5051,113 @@ const discovery = async (args) => {
5539
5051
  if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
5540
5052
  exit(1);
5541
5053
  };
5542
- /**
5543
- * `spinoff --title=<text> [--body=<text>] [--parent=<id>] [--severity=…] [--kind=…]` —
5544
- * park a non-blocking defect found mid-task as a `harness:managed` +
5545
- * `harness:status:pending` stub and emit `followup_captured` (#112). `--kind` adds a
5546
- * routing label (today `architecture-drift` pickup resolves it via the Architect's
5547
- * `update-architecture`, #148). The stub creation is fail-loud; the telemetry emit is
5548
- * best-effort (a failed emit surfaces a Warning but the capture stands).
5549
- */
5054
+ const designTokens = async (args) => {
5055
+ const action = args[0] ?? "validate";
5056
+ if (action === "validate") return designTokensValidate(args);
5057
+ if (action === "contrast") return designTokensContrast();
5058
+ if (action === "import") return designTokensImport(args);
5059
+ if (action === "export") return designTokensExport(args);
5060
+ throw new Error(`Usage: lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>. Unknown action "${action}".`);
5061
+ };
5062
+ const designTokensValidate = async (args) => {
5063
+ const config = await readHarnessConfig(cwd()).catch(() => void 0);
5064
+ const result = await runValidate({
5065
+ repoRoot: cwd(),
5066
+ scanDir: parseFlag(args, "scan"),
5067
+ extraExtensions: config?.design_tokens.scan_extensions
5068
+ });
5069
+ if (!result.tokensFound) {
5070
+ console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the harness never creates it).`);
5071
+ return;
5072
+ }
5073
+ if (result.ok) {
5074
+ console.log(`design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
5075
+ return;
5076
+ }
5077
+ console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
5078
+ for (const violation of result.violations) {
5079
+ const where = violation.file ? violation.line !== void 0 ? `${violation.file}:${violation.line} ` : `${violation.file} ` : "";
5080
+ console.error(` ${where}[${violation.kind}] ${violation.message}`);
5081
+ }
5082
+ exit(1);
5083
+ };
5084
+ const designTokensContrast = async () => {
5085
+ const result = await runContrast({ repoRoot: cwd() });
5086
+ if (!result.tokensFound) {
5087
+ console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5088
+ return;
5089
+ }
5090
+ const failures = result.pairs.filter((pair) => !pair.passes);
5091
+ if (result.ok) {
5092
+ console.log(result.pairsChecked === 0 ? `design-tokens contrast: no foreground/background pairs found (use the on-* convention or $extensions["com.lemony.contrast"]).` : `design-tokens contrast OK: ${result.pairsChecked} pair(s) meet their WCAG floor.`);
5093
+ return;
5094
+ }
5095
+ console.error(`design-tokens contrast found ${failures.length + result.problems.length} issue(s):`);
5096
+ for (const pair of failures) console.error(` ${pair.foreground} on ${pair.background} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5097
+ for (const problem of result.problems) console.error(` ${problem}`);
5098
+ exit(1);
5099
+ };
5100
+ const designTokensImport = async (args) => {
5101
+ const from = parseFlag(args, "from");
5102
+ if (from === void 0) throw new Error("Usage: lemony design-tokens import --from=<neutral-file> [--apply] [--only=<dotted,paths>]");
5103
+ const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
5104
+ const result = await runImport({
5105
+ repoRoot: cwd(),
5106
+ neutralPath: resolve(cwd(), from),
5107
+ apply: args.includes("--apply"),
5108
+ only
5109
+ });
5110
+ if (result.problems.length > 0) {
5111
+ console.error("design-tokens import could not run:");
5112
+ for (const problem of result.problems) console.error(` ${problem}`);
5113
+ exit(1);
5114
+ }
5115
+ if (result.applied) {
5116
+ console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${DESIGN_TOKENS_FILE}.`);
5117
+ 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(", ")}`);
5118
+ return;
5119
+ }
5120
+ const landing = result.changes.filter((change) => change.kind !== "unchanged");
5121
+ const news = landing.filter((change) => change.kind === "new").length;
5122
+ const changed = landing.length - news;
5123
+ console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
5124
+ for (const change of landing) {
5125
+ const detail = change.kind === "changed" ? `${change.before} → ${change.after}` : change.after;
5126
+ console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
5127
+ }
5128
+ if (landing.length > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
5129
+ };
5130
+ const designTokensExport = async (args) => {
5131
+ const toolState = parseFlag(args, "tool-state");
5132
+ const out = parseFlag(args, "out");
5133
+ if (out !== void 0 && args.includes("--record")) {
5134
+ console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
5135
+ exit(1);
5136
+ }
5137
+ const result = await runExport({
5138
+ repoRoot: cwd(),
5139
+ ...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
5140
+ ...out ? { outPath: resolve(cwd(), out) } : {},
5141
+ record: args.includes("--record")
5142
+ });
5143
+ if (!result.tokensFound) {
5144
+ console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; the harness never creates it).`);
5145
+ return;
5146
+ }
5147
+ if (result.problems.length > 0) {
5148
+ console.error("design-tokens export could not run:");
5149
+ for (const problem of result.problems) console.error(` ${problem}`);
5150
+ exit(1);
5151
+ }
5152
+ if (result.recorded) {
5153
+ console.log(`design-tokens export recorded: drift baseline stamped (${result.hash.slice(0, 12)}).`);
5154
+ return;
5155
+ }
5156
+ const creates = result.plan.filter((entry) => entry.kind === "create").length;
5157
+ const updates = result.plan.filter((entry) => entry.kind === "update").length;
5158
+ console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
5159
+ if (out !== void 0) console.log(` projection written to ${out}`);
5160
+ };
5550
5161
  const spinoff = async (args) => {
5551
5162
  const harnessVersion = await readHarnessVersion();
5552
5163
  const result = await runSpinoff({
@@ -5587,6 +5198,13 @@ const status = async () => {
5587
5198
  console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
5588
5199
  console.log(` Last session: ${report.lastSession ?? "(none)"}`);
5589
5200
  console.log(` Open discoveries: ${discoveries}`);
5201
+ if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
5202
+ };
5203
+ const DESIGN_TOOL_STATUS = {
5204
+ "n/a": "not in use",
5205
+ "never-projected": "declared, never exported (run /sync-design-tokens export)",
5206
+ "in-sync": "in sync",
5207
+ "export-pending": "export pending (run /sync-design-tokens export)"
5590
5208
  };
5591
5209
  const doctor = async () => {
5592
5210
  const report = await runDoctor({
@@ -5666,6 +5284,9 @@ const main = async () => {
5666
5284
  case "discovery":
5667
5285
  await discovery(args);
5668
5286
  return;
5287
+ case "design-tokens":
5288
+ await designTokens(args);
5289
+ return;
5669
5290
  case "spinoff":
5670
5291
  await spinoff(args);
5671
5292
  return;