@lemoncode/lemony 0.1.0 → 0.1.1-alpha.1

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 (49) hide show
  1. package/NOTICE +39 -0
  2. package/README.md +0 -1
  3. package/catalog/VERSION +1 -1
  4. package/catalog/agents/architect.md +4 -4
  5. package/catalog/agents/fit-assessment.md +1 -1
  6. package/catalog/agents/implementer.md +15 -8
  7. package/catalog/agents/orchestrator.md +204 -36
  8. package/catalog/agents/reviewer.md +7 -7
  9. package/catalog/agents/spec-author.md +7 -4
  10. package/catalog/agents/ui-designer.md +121 -15
  11. package/catalog/commands/add-capability.md +3 -3
  12. package/catalog/commands/resume.md +10 -4
  13. package/catalog/commands/spinoff.md +2 -2
  14. package/catalog/commands/sync-design-tokens.md +29 -0
  15. package/catalog/harness.config.schema.json +15 -16
  16. package/catalog/hooks/init.sh +11 -11
  17. package/catalog/hooks/lib/lemony.sh +3 -3
  18. package/catalog/hooks/lib/playbook-scan.sh +10 -11
  19. package/catalog/hooks/session-close.sh +7 -7
  20. package/catalog/schemas/tier2-events-history.md +11 -11
  21. package/catalog/schemas/tier2-events.md +46 -47
  22. package/catalog/skills/a11y-audit/SKILL.md +121 -0
  23. package/catalog/skills/bootstrap-architecture/SKILL.md +3 -3
  24. package/catalog/skills/build-ui/SKILL.md +147 -0
  25. package/catalog/skills/build-ui/accessibility.md +101 -0
  26. package/catalog/skills/build-ui/anti-slop.md +107 -0
  27. package/catalog/skills/code-explorer/SKILL.md +1 -1
  28. package/catalog/skills/design-critique/SKILL.md +110 -0
  29. package/catalog/skills/design-tool-sync/SKILL.md +120 -0
  30. package/catalog/skills/grill-ui/SKILL.md +248 -0
  31. package/catalog/skills/grill-ui/ui-handoff-format.md +149 -0
  32. package/catalog/skills/grill-with-docs/SKILL.md +9 -2
  33. package/catalog/skills/mutation-testing/SKILL.md +1 -1
  34. package/catalog/skills/note-side-finding/SKILL.md +1 -1
  35. package/catalog/skills/playbook-iterate/SKILL.md +2 -2
  36. package/catalog/skills/review-pr/SKILL.md +3 -3
  37. package/catalog/skills/task-closeout/SKILL.md +9 -8
  38. package/catalog/skills/update-architecture/SKILL.md +3 -3
  39. package/catalog/templates/claude-code/agents.md.tpl +27 -18
  40. package/catalog/templates/claude-code/docs/playbooks/README.md.tpl +1 -3
  41. package/catalog/templates/claude-code/harness.config.yml.tpl +8 -9
  42. package/dist/cli.mjs +1287 -1676
  43. package/package.json +13 -4
  44. package/catalog/agents/README.md +0 -29
  45. package/catalog/hooks/README.md +0 -56
  46. package/catalog/playbook-format.md +0 -198
  47. package/catalog/schemas/README.md +0 -13
  48. package/catalog/skills/README.md +0 -62
  49. 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,89 +10,21 @@ 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
- const DEPRECATED_CONFIG_KEYS = ["profile"];
48
- /** Committed JSON Schema the IDE reads via the `$schema` header (decision #53e/B1.5). */
20
+ const DEPRECATED_CONFIG_KEYS = ["profile", "agents"];
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
- const AGENTS = [
74
- "orchestrator",
75
- "spec-author",
76
- "implementer",
77
- "reviewer",
78
- "architect",
79
- "ui-designer"
80
- ];
81
- /** The hat — must always be present in the `agents` array (#53h). */
82
- 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
26
  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
27
  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
28
  const PATHS_DEFAULTS = {
97
29
  state: ".claude/state",
98
30
  skills: ".claude/skills",
@@ -102,13 +34,6 @@ const PATHS_DEFAULTS = {
102
34
  };
103
35
  //#endregion
104
36
  //#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
37
  const formatConfigError = (schema, error) => {
113
38
  return error.issues.flatMap((issue) => formatIssue(schema, issue)).join("\n");
114
39
  };
@@ -128,16 +53,8 @@ const formatIssue = (schema, issue) => {
128
53
  if (issue.code === "invalid_value") return [`${where} must be one of: ${issue.values.map((v) => JSON.stringify(v)).join(", ")}.`];
129
54
  return [`${where}: ${issue.message}`];
130
55
  };
131
- /** A `vendor_version` issue (top-level scalar) gets the example appended. */
132
56
  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
57
  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
58
  const validKeysAt = (schema, path) => {
142
59
  let shape = shapeOf(schema);
143
60
  for (const segment of path) {
@@ -152,12 +69,6 @@ const shapeOf = (schema) => {
152
69
  while (current?.def?.innerType) current = current.def.innerType;
153
70
  return current?.shape;
154
71
  };
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
72
  const nearestKey = (key, candidates) => {
162
73
  let best = null;
163
74
  let bestDistance = Infinity;
@@ -185,19 +96,6 @@ const levenshtein = (a, b) => {
185
96
  };
186
97
  //#endregion
187
98
  //#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
99
  const pathsSchema = z.object({
202
100
  state: z.string().default(PATHS_DEFAULTS.state),
203
101
  skills: z.string().default(PATHS_DEFAULTS.skills),
@@ -209,45 +107,20 @@ const taskStorageSchema = z.object({
209
107
  type: z.enum(TASK_STORAGE_TYPES),
210
108
  repo: z.string().regex(TASK_STORAGE_REPO_PATTERN)
211
109
  }).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
110
  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
111
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
112
+ const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
230
113
  const harnessConfigSchema = z.object({
231
114
  vendor_version: z.string().regex(VENDOR_VERSION_REGEX),
232
115
  target: z.enum(TARGETS),
233
116
  task_storage: taskStorageSchema,
234
- agents: z.array(z.enum(AGENTS)).refine((agents) => agents.includes(REQUIRED_AGENT), { error: `agents must include "${REQUIRED_AGENT}" (the orchestrator is the hat).` }),
235
117
  paths: pathsSchema,
236
118
  rollback: rollbackSchema,
237
- telemetry: telemetrySchema
119
+ telemetry: telemetrySchema,
120
+ design_tokens: designTokensSchema
238
121
  }).strict();
239
122
  //#endregion
240
123
  //#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
124
  const readHarnessConfig = async (repoRoot) => {
252
125
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
253
126
  let raw;
@@ -272,17 +145,11 @@ const readHarnessConfig = async (repoRoot) => {
272
145
  };
273
146
  //#endregion
274
147
  //#region src/config/compare-version.ts
275
- /** Prerelease tags in ascending semver precedence (alpha < beta < rc < release). */
276
148
  const PRERELEASE_RANK = {
277
149
  alpha: 0,
278
150
  beta: 1,
279
151
  rc: 2
280
152
  };
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
153
  const VERSION_PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/;
287
154
  const parse$1 = (raw) => {
288
155
  const m = VERSION_PARTS.exec(raw);
@@ -305,17 +172,6 @@ const parse$1 = (raw) => {
305
172
  }
306
173
  };
307
174
  };
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
175
  const compareVendorVersion = (a, b) => {
320
176
  const pa = parse$1(a);
321
177
  const pb = parse$1(b);
@@ -334,57 +190,18 @@ const compareVendorVersion = (a, b) => {
334
190
  };
335
191
  //#endregion
336
192
  //#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
193
  const setConfigValues = (rawYaml, updates) => {
353
194
  const doc = parseDocument(rawYaml);
354
195
  for (const [key, value] of Object.entries(updates)) if (doc.has(key)) doc.set(key, value);
355
196
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
356
197
  return doc.toString();
357
198
  };
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
199
  const writeConfigValues = async (repoRoot, updates) => {
364
200
  const configPath = join(repoRoot, HARNESS_CONFIG_FILENAME);
365
201
  await writeFile(configPath, setConfigValues(await readFile(configPath, "utf8"), updates));
366
202
  };
367
203
  //#endregion
368
204
  //#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
205
  const pointerScalar = z.union([
389
206
  z.string(),
390
207
  z.number(),
@@ -400,14 +217,6 @@ const pointerFrontmatterSchema = z.object({
400
217
  Object.keys(pointerFrontmatterSchema.shape);
401
218
  //#endregion
402
219
  //#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
220
  const buildEnvelope = (input) => {
412
221
  const ts = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
413
222
  const envelope = {
@@ -422,15 +231,6 @@ const buildEnvelope = (input) => {
422
231
  };
423
232
  //#endregion
424
233
  //#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
234
  const EVENT_TYPES = [
435
235
  "session_closed",
436
236
  "spec_created",
@@ -442,7 +242,6 @@ const EVENT_TYPES = [
442
242
  "followup_captured",
443
243
  "step_completed"
444
244
  ];
445
- /** Reason codes accepted by `session_closed` — Claude Code SessionEnd values plus `manual` for `/pause`. */
446
245
  const SESSION_CLOSE_REASONS = [
447
246
  "clear",
448
247
  "resume",
@@ -452,38 +251,23 @@ const SESSION_CLOSE_REASONS = [
452
251
  "other",
453
252
  "manual"
454
253
  ];
455
- /** Task-fit dial value, recorded on `task_done` (decisions #57–#61). */
456
254
  const TASK_LEVELS = [
457
255
  "L1",
458
256
  "L2",
459
257
  "L3"
460
258
  ];
461
- /** Bug severity, recorded on `bug_post_merge` (P8 meta-test). */
462
259
  const BUG_SEVERITIES = [
463
260
  "low",
464
261
  "medium",
465
262
  "high",
466
263
  "critical"
467
264
  ];
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
265
  const IMPLEMENTATION_MODES = ["all_at_once", "step_by_step"];
473
- /** Human checkpoint outcome, recorded on `step_completed` (#176). */
474
266
  const CHECKPOINT_RESULTS = [
475
267
  "ok",
476
268
  "changes",
477
269
  "ok_downgrade"
478
270
  ];
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
271
  const ATTRIBUTION_KINDS = [
488
272
  "agent",
489
273
  "skill",
@@ -492,12 +276,6 @@ const ATTRIBUTION_KINDS = [
492
276
  //#endregion
493
277
  //#region src/events/git-user.ts
494
278
  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
279
  const readGitUserEmail = async (cwd) => {
502
280
  try {
503
281
  const { stdout } = await execFileAsync$1("git", ["config", "user.email"], { cwd });
@@ -509,27 +287,11 @@ const readGitUserEmail = async (cwd) => {
509
287
  };
510
288
  //#endregion
511
289
  //#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
290
  const ISO_Z_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
518
291
  const isoTimestamp = () => z.string().regex(ISO_Z_REGEX);
519
292
  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
293
  const shortText = () => z.string().min(1).max(200);
527
294
  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
295
  const envelopeBase = z.object({
534
296
  ts: isoTimestamp(),
535
297
  user: nonEmpty(),
@@ -537,13 +299,6 @@ const envelopeBase = z.object({
537
299
  task_id: nonEmpty().optional(),
538
300
  harness_version: nonEmpty()
539
301
  });
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
302
  const attributionFields = {
548
303
  attributed_kind: z.enum(ATTRIBUTION_KINDS).optional(),
549
304
  attributed_name: shortText().optional()
@@ -608,11 +363,6 @@ const stepCompletedSchema = envelopeBase.extend({
608
363
  checkpoint_result: z.enum(CHECKPOINT_RESULTS),
609
364
  ...attributionFields
610
365
  }).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
366
  const eventSchema = z.discriminatedUnion("type", [
617
367
  sessionClosedSchema,
618
368
  specCreatedSchema,
@@ -626,7 +376,6 @@ const eventSchema = z.discriminatedUnion("type", [
626
376
  ]);
627
377
  //#endregion
628
378
  //#region src/events/emit.ts
629
- /** Per-type fields the CLI must coerce from `--key=value` strings to numbers. */
630
379
  const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
631
380
  "session_active_h",
632
381
  "requirements",
@@ -639,11 +388,9 @@ const NUMERIC_FIELDS = /* @__PURE__ */ new Set([
639
388
  "steps",
640
389
  "review_iterations"
641
390
  ]);
642
- /** Per-type fields the CLI must coerce from `--key=value` strings to booleans. */
643
391
  const BOOLEAN_FIELDS = /* @__PURE__ */ new Set(["auto_close"]);
644
392
  const EVENTS_RELPATH$1 = join(".claude", "state", "events.jsonl");
645
393
  const isEventType = (value) => EVENT_TYPES.includes(value);
646
- /** Coerce a raw `--key=value` string to its declared scalar type. */
647
394
  const coerceField = (key, raw) => {
648
395
  if (NUMERIC_FIELDS.has(key)) {
649
396
  const num = Number(raw);
@@ -657,15 +404,6 @@ const coerceField = (key, raw) => {
657
404
  }
658
405
  return raw;
659
406
  };
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
407
  const parseFlags = (args) => {
670
408
  const result = {};
671
409
  for (let i = 0; i < args.length; i++) {
@@ -679,14 +417,6 @@ const parseFlags = (args) => {
679
417
  }
680
418
  return result;
681
419
  };
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
420
  const runEmit = async (options) => {
691
421
  const { repoRoot, harnessVersion, args } = options;
692
422
  const [rawType, ...rest] = args;
@@ -723,7 +453,6 @@ const runEmit = async (options) => {
723
453
  };
724
454
  //#endregion
725
455
  //#region src/labels/labels.constant.ts
726
- /** 8 mutually-exclusive status labels (#55c), orange→green by lifecycle order. */
727
456
  const STATUS_LABELS = [
728
457
  {
729
458
  name: "harness:status:pending",
@@ -766,7 +495,6 @@ const STATUS_LABELS = [
766
495
  description: "Managed task: merged and closed."
767
496
  }
768
497
  ];
769
- /** 4 presence flags (#55d, B2.3) — no value, presence is the signal. */
770
498
  const FLAG_LABELS = [
771
499
  {
772
500
  name: "harness:managed",
@@ -786,10 +514,14 @@ const FLAG_LABELS = [
786
514
  {
787
515
  name: "harness:architecture-drift",
788
516
  color: "006b75",
789
- description: "Captured architecture.md drift (#148); resolved by the Architect at pickup, not a code change."
517
+ description: "Captured architecture.md drift; resolved by the Architect at pickup, not a code change."
518
+ },
519
+ {
520
+ name: "harness:needs-design",
521
+ color: "d4548d",
522
+ description: "Task touches UI; a design handoff (ui-handoff.md) is owed before spec-ready."
790
523
  }
791
524
  ];
792
- /** 6 discovery types (#55e), presence-based and multi-applicable, alert-red. */
793
525
  const DISCOVERY_LABELS = [
794
526
  ["T1", "contradiction"],
795
527
  ["T2", "unspecified decision"],
@@ -802,10 +534,6 @@ const DISCOVERY_LABELS = [
802
534
  color: "b60205",
803
535
  description: `Discovery (${tier}): ${meaning}.`
804
536
  }));
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
537
  const HARNESS_LABELS = [
810
538
  ...STATUS_LABELS,
811
539
  ...FLAG_LABELS,
@@ -813,13 +541,6 @@ const HARNESS_LABELS = [
813
541
  ];
814
542
  //#endregion
815
543
  //#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
544
  const diffLabels = (existing, desired) => {
824
545
  const byName = new Map(existing.map((label) => [label.name, label]));
825
546
  const missing = [];
@@ -839,16 +560,6 @@ const diffLabels = (existing, desired) => {
839
560
  };
840
561
  //#endregion
841
562
  //#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
563
  const syncLabels = async (repoSlug, provider, desired = HARNESS_LABELS) => {
853
564
  if (repoSlug === "OWNER/REPO") return {
854
565
  status: "skipped",
@@ -920,22 +631,6 @@ const failureReason$2 = (action, result) => {
920
631
  };
921
632
  //#endregion
922
633
  //#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
634
  const ensureLabels = async (repoSlug, provider) => {
940
635
  try {
941
636
  return await syncLabels(repoSlug, provider);
@@ -951,17 +646,6 @@ const ensureLabels = async (repoSlug, provider) => {
951
646
  };
952
647
  //#endregion
953
648
  //#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
649
  const deleteLabels = async (taskStorageRepo, provider) => {
966
650
  if (taskStorageRepo === "OWNER/REPO") return {
967
651
  status: "skipped",
@@ -986,39 +670,17 @@ const deleteLabels = async (taskStorageRepo, provider) => {
986
670
  };
987
671
  //#endregion
988
672
  //#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
673
  const formatLabelSync = (sync) => {
996
674
  if (!sync) return [];
997
675
  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
676
  return [`Labels not synced (${sync.status}): ${sync.reason}`, "Run `lemony doctor` to re-check once resolved."];
999
677
  };
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
678
  const formatLabelSelfHeal = (sync) => {
1011
679
  if (!sync) return [];
1012
680
  if (sync.created.length + sync.updated.length === 0) return [];
1013
681
  const partialFailure = sync.status === "failed" ? " (warning: some harness:* labels could not be synced — run `lemony doctor`)" : "";
1014
682
  return [`Synced ${sync.created.length} missing and ${sync.updated.length} drifted harness:* labels before continuing.${partialFailure}`];
1015
683
  };
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
684
  const formatLabelDeletion = (deletion, requested) => {
1023
685
  if (!requested) return [" Labels left in place (they live on remote issues). Re-run `uninstall --labels` to delete the harness:* labels."];
1024
686
  if (!deletion) return [];
@@ -1141,26 +803,13 @@ const createTaskTrackerProvider = (type, runCommand) => {
1141
803
  };
1142
804
  //#endregion
1143
805
  //#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
806
  const PAUSED_STATUS_LABEL = "harness:status:paused-for-clarification";
1150
807
  //#endregion
1151
808
  //#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
809
  const buildStatusLabel = (status) => `harness:status:${status}`;
1159
- /** Build the full discovery flag name from its tier. */
1160
810
  const buildDiscoveryLabel = (tier) => `harness:discovery:${tier}`;
1161
811
  //#endregion
1162
812
  //#region src/discovery/discovery.model.ts
1163
- /** The six discovery tiers (T1–T6); the issue carries `harness:discovery:T<n>`. */
1164
813
  const DISCOVERY_TIERS = [
1165
814
  "T1",
1166
815
  "T2",
@@ -1169,12 +818,6 @@ const DISCOVERY_TIERS = [
1169
818
  "T5",
1170
819
  "T6"
1171
820
  ];
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
821
  const RESUMABLE_STATUSES = [
1179
822
  "spec-in-progress",
1180
823
  "in-progress",
@@ -1182,18 +825,6 @@ const RESUMABLE_STATUSES = [
1182
825
  ];
1183
826
  //#endregion
1184
827
  //#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
828
  const runDiscovery = async (inputs) => {
1198
829
  const action = parseAction(inputs.action);
1199
830
  const taskId = requireFlag$1(inputs.taskId, "task-id");
@@ -1242,7 +873,6 @@ const runDiscovery = async (inputs) => {
1242
873
  labelSync
1243
874
  };
1244
875
  };
1245
- /** Pause: leave the current status, enter paused, flag the tier. */
1246
876
  const pauseLabelSteps = (provider, ref, tier, status) => [
1247
877
  {
1248
878
  label: `removed ${buildStatusLabel(status)}`,
@@ -1257,7 +887,6 @@ const pauseLabelSteps = (provider, ref, tier, status) => [
1257
887
  run: () => provider.addLabel(ref, buildDiscoveryLabel(tier))
1258
888
  }
1259
889
  ];
1260
- /** Resume: clear the tier flag, leave paused, restore the prior status. */
1261
890
  const resumeLabelSteps = (provider, ref, tier, status) => [
1262
891
  {
1263
892
  label: `removed ${buildDiscoveryLabel(tier)}`,
@@ -1298,55 +927,1099 @@ const failureReason$1 = (result) => {
1298
927
  return `\`gh\` failed (exit ${result.code})${suffix}${detail ? `: ${detail}` : ""}`;
1299
928
  };
1300
929
  //#endregion
930
+ //#region src/fs/exists.ts
931
+ const pathExists = async (path) => {
932
+ try {
933
+ await stat(path);
934
+ return true;
935
+ } catch {
936
+ return false;
937
+ }
938
+ };
939
+ //#endregion
940
+ //#region src/fs/list-files.ts
941
+ const listFiles = async (dir) => {
942
+ const entries = await readdir(dir, { withFileTypes: true });
943
+ return (await Promise.all(entries.map(async (entry) => {
944
+ if (entry.isDirectory()) return (await listFiles(join(dir, entry.name))).map((rel) => join(entry.name, rel));
945
+ return [entry.name];
946
+ }))).flat();
947
+ };
948
+ //#endregion
949
+ //#region src/fs/prune-empty-dirs.ts
950
+ const pruneEmptyDirs = async (repoRoot, removedFiles) => {
951
+ const dirs = /* @__PURE__ */ new Set();
952
+ for (const relPath of removedFiles) {
953
+ let dir = dirname(relPath);
954
+ while (dir !== "." && dir !== sep) {
955
+ dirs.add(dir);
956
+ dir = dirname(dir);
957
+ }
958
+ }
959
+ const ordered = [...dirs].toSorted((a, b) => b.length - a.length);
960
+ for (const dir of ordered) try {
961
+ await rmdir(join(repoRoot, dir));
962
+ } catch {}
963
+ };
964
+ //#endregion
965
+ //#region src/fs/write-managed.ts
966
+ const writeManaged = async (root, relPath, content, executable) => {
967
+ const dest = join(root, relPath);
968
+ await mkdir(dirname(dest), { recursive: true });
969
+ await writeFile(dest, content);
970
+ await chmod(dest, executable ? 493 : 420);
971
+ };
972
+ //#endregion
973
+ //#region src/fs/symlink-guard.ts
974
+ const assertNoSymlinkTraversal = async (root, relPaths) => {
975
+ const components = /* @__PURE__ */ new Set();
976
+ for (const relPath of relPaths) {
977
+ let current = "";
978
+ for (const part of relPath.split(sep)) {
979
+ if (part.length === 0) continue;
980
+ current = current ? join(current, part) : part;
981
+ components.add(current);
982
+ }
983
+ }
984
+ const offenders = (await Promise.all([...components].map(async (rel) => {
985
+ try {
986
+ return (await lstat(join(root, rel))).isSymbolicLink() ? rel : null;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }))).filter((rel) => rel !== null);
991
+ 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"));
992
+ };
993
+ //#endregion
994
+ //#region src/design-tokens/design-tokens.constant.ts
995
+ const DESIGN_TOKENS_FILE = "docs/design-tokens.json";
996
+ const SCANNED_EXTENSIONS = [
997
+ ".css",
998
+ ".scss",
999
+ ".sass",
1000
+ ".less",
1001
+ ".styl",
1002
+ ".pcss",
1003
+ ".postcss",
1004
+ ".ts",
1005
+ ".tsx",
1006
+ ".js",
1007
+ ".jsx",
1008
+ ".mjs",
1009
+ ".cjs",
1010
+ ".vue",
1011
+ ".svelte",
1012
+ ".astro",
1013
+ ".mdx",
1014
+ ".html"
1015
+ ];
1016
+ const STYLESHEET_EXTENSIONS = [
1017
+ ".css",
1018
+ ".scss",
1019
+ ".sass",
1020
+ ".less",
1021
+ ".styl",
1022
+ ".pcss",
1023
+ ".postcss"
1024
+ ];
1025
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([
1026
+ "node_modules",
1027
+ ".git",
1028
+ "dist",
1029
+ "build",
1030
+ "out",
1031
+ "coverage",
1032
+ ".next",
1033
+ ".cache",
1034
+ ".claude"
1035
+ ]);
1036
+ //#endregion
1037
+ //#region src/design-tokens/design-tokens.model.ts
1038
+ const TOKEN_TIERS = [
1039
+ "primitive",
1040
+ "semantic",
1041
+ "component"
1042
+ ];
1043
+ //#endregion
1044
+ //#region src/design-tokens/token-parse.ts
1045
+ const aliasTarget = (value) => {
1046
+ if (typeof value !== "string") return void 0;
1047
+ const match = /^\{([^{}\s]+)\}$/.exec(value.trim());
1048
+ return match ? match[1] : void 0;
1049
+ };
1050
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1051
+ //#endregion
1052
+ //#region src/design-tokens/design-tokens.ts
1053
+ const runValidate = async (inputs) => {
1054
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1055
+ if (!await pathExists(tokenPath)) return {
1056
+ ok: true,
1057
+ tokensFound: false,
1058
+ tokenCount: 0,
1059
+ filesScanned: 0,
1060
+ violations: []
1061
+ };
1062
+ const fileCheck = validateTokenFile(await readFile(tokenPath, "utf8"));
1063
+ if (!fileCheck.ok) return {
1064
+ ok: false,
1065
+ tokensFound: true,
1066
+ tokenCount: fileCheck.tokenCount,
1067
+ filesScanned: 0,
1068
+ violations: fileCheck.violations
1069
+ };
1070
+ const scanRoot = inputs.scanDir ? join(inputs.repoRoot, inputs.scanDir) : inputs.repoRoot;
1071
+ const extensions = mergeExtensions(inputs.extraExtensions);
1072
+ const sourceViolations = await scanSource(inputs.repoRoot, scanRoot, extensions);
1073
+ return {
1074
+ ok: sourceViolations.violations.length === 0,
1075
+ tokensFound: true,
1076
+ tokenCount: fileCheck.tokenCount,
1077
+ filesScanned: sourceViolations.filesScanned,
1078
+ violations: sourceViolations.violations
1079
+ };
1080
+ };
1081
+ const validateTokenFile = (raw) => {
1082
+ let parsed;
1083
+ try {
1084
+ parsed = JSON.parse(raw);
1085
+ } catch (error) {
1086
+ return {
1087
+ ok: false,
1088
+ tokenCount: 0,
1089
+ violations: [{
1090
+ kind: "invalid-json",
1091
+ message: `${DESIGN_TOKENS_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
1092
+ }]
1093
+ };
1094
+ }
1095
+ if (!isRecord(parsed)) return {
1096
+ ok: false,
1097
+ tokenCount: 0,
1098
+ violations: [{
1099
+ kind: "malformed-token-file",
1100
+ message: `${DESIGN_TOKENS_FILE} must be a JSON object with primitive/semantic/component tiers.`
1101
+ }]
1102
+ };
1103
+ const violations = [];
1104
+ for (const key of Object.keys(parsed)) {
1105
+ if (key.startsWith("$")) continue;
1106
+ if (!TOKEN_TIERS.includes(key)) violations.push({
1107
+ kind: "malformed-token-file",
1108
+ message: `unknown top-level group "${key}" — only ${TOKEN_TIERS.join("/")} (and $-prefixed metadata) are allowed at the top level.`
1109
+ });
1110
+ }
1111
+ const tokens = /* @__PURE__ */ new Map();
1112
+ for (const tier of TOKEN_TIERS) {
1113
+ const group = parsed[tier];
1114
+ if (group === void 0) continue;
1115
+ if (!isRecord(group)) {
1116
+ violations.push({
1117
+ kind: "malformed-token-file",
1118
+ message: `tier "${tier}" must be an object of token groups.`
1119
+ });
1120
+ continue;
1121
+ }
1122
+ collectTokens$1(group, tier, tier, tokens, violations);
1123
+ }
1124
+ violations.push(...collectAliasViolations(tokens));
1125
+ return {
1126
+ ok: violations.length === 0,
1127
+ tokenCount: tokens.size,
1128
+ violations
1129
+ };
1130
+ };
1131
+ const collectTokens$1 = (node, tier, path, out, violations) => {
1132
+ for (const [key, child] of Object.entries(node)) {
1133
+ if (key.startsWith("$")) continue;
1134
+ const childPath = `${path}.${key}`;
1135
+ if (!isRecord(child)) {
1136
+ violations.push({
1137
+ kind: "malformed-token-file",
1138
+ message: `"${childPath}" must be a token object or a group, not a bare value.`
1139
+ });
1140
+ continue;
1141
+ }
1142
+ if ("$value" in child) {
1143
+ out.set(childPath, {
1144
+ tier,
1145
+ value: child.$value
1146
+ });
1147
+ const stray = Object.keys(child).filter((k) => !k.startsWith("$"));
1148
+ if (stray.length > 0) violations.push({
1149
+ kind: "malformed-token-file",
1150
+ message: `token "${childPath}" has both a $value and nested group(s) (${stray.join(", ")}) — a token cannot also be a group.`
1151
+ });
1152
+ } else collectTokens$1(child, tier, childPath, out, violations);
1153
+ }
1154
+ };
1155
+ const collectAliasViolations = (tokens) => {
1156
+ const violations = [];
1157
+ for (const [path, token] of tokens) {
1158
+ const alias = aliasTarget(token.value);
1159
+ if (alias === void 0) continue;
1160
+ if (token.tier === "primitive") {
1161
+ violations.push({
1162
+ kind: "primitive-not-literal",
1163
+ message: `primitive token "${path}" must hold a literal value, not the alias "{${alias}}".`
1164
+ });
1165
+ continue;
1166
+ }
1167
+ if (!tokens.has(alias)) {
1168
+ violations.push({
1169
+ kind: "unresolved-alias",
1170
+ message: `token "${path}" references "{${alias}}", which does not exist.`
1171
+ });
1172
+ continue;
1173
+ }
1174
+ if (aliasChainCycles(path, tokens)) violations.push({
1175
+ kind: "cyclic-alias",
1176
+ message: `token "${path}" has an alias chain that never resolves to a literal value (alias cycle).`
1177
+ });
1178
+ }
1179
+ return violations;
1180
+ };
1181
+ const aliasChainCycles = (start, tokens) => {
1182
+ const seen = /* @__PURE__ */ new Set();
1183
+ let current = start;
1184
+ while (current !== void 0) {
1185
+ if (seen.has(current)) return true;
1186
+ seen.add(current);
1187
+ const token = tokens.get(current);
1188
+ if (token === void 0) return false;
1189
+ current = aliasTarget(token.value);
1190
+ }
1191
+ return false;
1192
+ };
1193
+ const mergeExtensions = (extra) => {
1194
+ const set = new Set(SCANNED_EXTENSIONS);
1195
+ for (const raw of extra ?? []) {
1196
+ const lower = raw.trim().toLowerCase();
1197
+ if (lower.length === 0) continue;
1198
+ set.add(lower.startsWith(".") ? lower : `.${lower}`);
1199
+ }
1200
+ return set;
1201
+ };
1202
+ const scanSource = async (repoRoot, scanRoot, extensions) => {
1203
+ if (!await pathExists(scanRoot)) return {
1204
+ filesScanned: 0,
1205
+ violations: []
1206
+ };
1207
+ const files = (await collectSourceFiles(scanRoot, extensions)).toSorted();
1208
+ const violations = [];
1209
+ let filesScanned = 0;
1210
+ for (const file of files) {
1211
+ const content = await readFile(file, "utf8").catch(() => void 0);
1212
+ if (content === void 0) continue;
1213
+ filesScanned += 1;
1214
+ const relPath = relative(repoRoot, file).split(sep).join("/");
1215
+ violations.push(...scanFile(relPath, extname(file).toLowerCase(), content));
1216
+ }
1217
+ return {
1218
+ filesScanned,
1219
+ violations
1220
+ };
1221
+ };
1222
+ const collectSourceFiles = async (dir, extensions) => {
1223
+ const entries = await readdir(dir, { withFileTypes: true });
1224
+ return (await Promise.all(entries.map(async (entry) => {
1225
+ const full = join(dir, entry.name);
1226
+ if (entry.isDirectory()) {
1227
+ if (IGNORED_DIRS.has(entry.name)) return [];
1228
+ return collectSourceFiles(full, extensions);
1229
+ }
1230
+ return extensions.has(extname(entry.name).toLowerCase()) ? [full] : [];
1231
+ }))).flat();
1232
+ };
1233
+ const HEX_COLOR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/;
1234
+ const FUNCTIONAL_COLOR = /\b(?:rgb|rgba|hsl|hsla)\s*\(/i;
1235
+ const PX_DIMENSION = /\b(\d*\.?\d+)px\b/g;
1236
+ const EXEMPT_DIMENSIONS = /* @__PURE__ */ new Set(["0", "1"]);
1237
+ const scanFile = (relPath, ext, content) => {
1238
+ const isStylesheet = STYLESHEET_EXTENSIONS.includes(ext);
1239
+ const violations = [];
1240
+ content.split("\n").forEach((line, index) => {
1241
+ if (line.includes("design-tokens-allow")) return;
1242
+ const lineNo = index + 1;
1243
+ if (HEX_COLOR.test(line) || FUNCTIONAL_COLOR.test(line)) violations.push({
1244
+ kind: "hardcoded-color",
1245
+ message: `raw color literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
1246
+ file: relPath,
1247
+ line: lineNo
1248
+ });
1249
+ if (isStylesheet && hasRawDimension(line)) violations.push({
1250
+ kind: "hardcoded-dimension",
1251
+ message: `raw dimension literal — reference a token from ${DESIGN_TOKENS_FILE} instead.`,
1252
+ file: relPath,
1253
+ line: lineNo
1254
+ });
1255
+ });
1256
+ return violations;
1257
+ };
1258
+ const hasRawDimension = (line) => {
1259
+ for (const match of line.matchAll(PX_DIMENSION)) {
1260
+ const value = match[1];
1261
+ if (value !== void 0 && !EXEMPT_DIMENSIONS.has(value)) return true;
1262
+ }
1263
+ return false;
1264
+ };
1265
+ //#endregion
1266
+ //#region src/design-tokens/color-contrast.ts
1267
+ const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
1268
+ const parseColor = (raw) => {
1269
+ const value = raw.trim();
1270
+ if (value.startsWith("#")) return parseHex(value);
1271
+ const fn = /^(rgba?|hsla?)\(\s*(.*?)\s*\)$/i.exec(value);
1272
+ if (!fn) return void 0;
1273
+ const kind = fn[1]?.toLowerCase() ?? "";
1274
+ const body = fn[2] ?? "";
1275
+ return kind.startsWith("rgb") ? parseRgb(body) : parseHsl(body);
1276
+ };
1277
+ const expandHex = (s) => s.split("").map((c) => c + c).join("");
1278
+ const parseHex = (hex) => {
1279
+ const h = hex.slice(1);
1280
+ if (!/^[0-9a-fA-F]+$/.test(h)) return void 0;
1281
+ const full = h.length === 3 || h.length === 4 ? expandHex(h) : h.length === 6 || h.length === 8 ? h : void 0;
1282
+ if (full === void 0) return void 0;
1283
+ return {
1284
+ r: parseInt(full.slice(0, 2), 16),
1285
+ g: parseInt(full.slice(2, 4), 16),
1286
+ b: parseInt(full.slice(4, 6), 16),
1287
+ a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1
1288
+ };
1289
+ };
1290
+ const splitBody = (body) => body.replace(/\//g, " ").split(/[\s,]+/).map((token) => token.trim()).filter((token) => token.length > 0);
1291
+ const parseChannel = (token) => {
1292
+ if (token.endsWith("%")) {
1293
+ const pct = Number(token.slice(0, -1));
1294
+ return Number.isFinite(pct) ? clamp(pct / 100, 0, 1) * 255 : void 0;
1295
+ }
1296
+ const n = Number(token);
1297
+ return Number.isFinite(n) ? clamp(n, 0, 255) : void 0;
1298
+ };
1299
+ const parseAlpha = (token) => {
1300
+ if (token.endsWith("%")) {
1301
+ const pct = Number(token.slice(0, -1));
1302
+ return Number.isFinite(pct) ? clamp(pct / 100, 0, 1) : 1;
1303
+ }
1304
+ const n = Number(token);
1305
+ return Number.isFinite(n) ? clamp(n, 0, 1) : 1;
1306
+ };
1307
+ const parseRgb = (body) => {
1308
+ const parts = splitBody(body);
1309
+ if (parts.length < 3) return void 0;
1310
+ const r = parseChannel(parts[0] ?? "");
1311
+ const g = parseChannel(parts[1] ?? "");
1312
+ const b = parseChannel(parts[2] ?? "");
1313
+ if (r === void 0 || g === void 0 || b === void 0) return void 0;
1314
+ const a = parts[3] !== void 0 ? parseAlpha(parts[3]) : 1;
1315
+ return {
1316
+ r: Math.round(r),
1317
+ g: Math.round(g),
1318
+ b: Math.round(b),
1319
+ a
1320
+ };
1321
+ };
1322
+ const parseHsl = (body) => {
1323
+ const parts = splitBody(body);
1324
+ if (parts.length < 3) return void 0;
1325
+ const h = Number((parts[0] ?? "").replace(/deg$/i, ""));
1326
+ const s = parsePercent(parts[1] ?? "");
1327
+ const l = parsePercent(parts[2] ?? "");
1328
+ if (!Number.isFinite(h) || s === void 0 || l === void 0) return void 0;
1329
+ const a = parts[3] !== void 0 ? parseAlpha(parts[3]) : 1;
1330
+ return {
1331
+ ...hslToRgb((h % 360 + 360) % 360, s, l),
1332
+ a
1333
+ };
1334
+ };
1335
+ const parsePercent = (token) => {
1336
+ const n = Number(token.endsWith("%") ? token.slice(0, -1) : token);
1337
+ return Number.isFinite(n) ? clamp(n / 100, 0, 1) : void 0;
1338
+ };
1339
+ const hslToRgb = (h, s, l) => {
1340
+ const c = (1 - Math.abs(2 * l - 1)) * s;
1341
+ const x = c * (1 - Math.abs(h / 60 % 2 - 1));
1342
+ const m = l - c / 2;
1343
+ const [r1, g1, b1] = h < 60 ? [
1344
+ c,
1345
+ x,
1346
+ 0
1347
+ ] : h < 120 ? [
1348
+ x,
1349
+ c,
1350
+ 0
1351
+ ] : h < 180 ? [
1352
+ 0,
1353
+ c,
1354
+ x
1355
+ ] : h < 240 ? [
1356
+ 0,
1357
+ x,
1358
+ c
1359
+ ] : h < 300 ? [
1360
+ x,
1361
+ 0,
1362
+ c
1363
+ ] : [
1364
+ c,
1365
+ 0,
1366
+ x
1367
+ ];
1368
+ return {
1369
+ r: Math.round((r1 + m) * 255),
1370
+ g: Math.round((g1 + m) * 255),
1371
+ b: Math.round((b1 + m) * 255)
1372
+ };
1373
+ };
1374
+ const WHITE = {
1375
+ r: 255,
1376
+ g: 255,
1377
+ b: 255,
1378
+ a: 1
1379
+ };
1380
+ const flatten = (color, backdrop) => ({
1381
+ r: color.r * color.a + backdrop.r * (1 - color.a),
1382
+ g: color.g * color.a + backdrop.g * (1 - color.a),
1383
+ b: color.b * color.a + backdrop.b * (1 - color.a),
1384
+ a: 1
1385
+ });
1386
+ const linearizeChannel = (channel) => {
1387
+ const s = channel / 255;
1388
+ return s <= .03928 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
1389
+ };
1390
+ const relativeLuminance = ({ r, g, b }) => .2126 * linearizeChannel(r) + .7152 * linearizeChannel(g) + .0722 * linearizeChannel(b);
1391
+ const contrastRatio = (foreground, background) => {
1392
+ const bg = background.a < 1 ? flatten(background, WHITE) : background;
1393
+ const l1 = relativeLuminance(foreground.a < 1 ? flatten(foreground, bg) : foreground);
1394
+ const l2 = relativeLuminance(bg);
1395
+ const light = Math.max(l1, l2);
1396
+ const dark = Math.min(l1, l2);
1397
+ return Math.round((light + .05) / (dark + .05) * 100) / 100;
1398
+ };
1399
+ //#endregion
1400
+ //#region src/design-tokens/contrast.constant.ts
1401
+ const CONTRAST_EXTENSION = "com.lemony.contrast";
1402
+ const MODES_EXTENSION = "com.lemony.modes";
1403
+ const BASE_MODE = "base";
1404
+ const DEFAULT_LEVEL = "text";
1405
+ const WCAG_FLOORS = {
1406
+ text: 4.5,
1407
+ large: 3,
1408
+ "non-text": 3
1409
+ };
1410
+ //#endregion
1411
+ //#region src/design-tokens/contrast.model.ts
1412
+ const CONTRAST_LEVELS = [
1413
+ "text",
1414
+ "large",
1415
+ "non-text"
1416
+ ];
1417
+ //#endregion
1418
+ //#region src/design-tokens/contrast.ts
1419
+ const runContrast = async (inputs) => {
1420
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1421
+ if (!await pathExists(tokenPath)) return emptyResult$1(false);
1422
+ const raw = await readFile(tokenPath, "utf8");
1423
+ let parsed;
1424
+ try {
1425
+ parsed = JSON.parse(raw);
1426
+ } catch (error) {
1427
+ return {
1428
+ ...emptyResult$1(true),
1429
+ ok: false,
1430
+ problems: [`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`]
1431
+ };
1432
+ }
1433
+ if (!isRecord(parsed)) return {
1434
+ ...emptyResult$1(true),
1435
+ ok: false,
1436
+ problems: [`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`]
1437
+ };
1438
+ const tokens = collectTokens(parsed);
1439
+ const problems = [];
1440
+ const pairs = measurePairs(discoverPairs(tokens, problems), declaredModes(tokens), tokens);
1441
+ return {
1442
+ ok: problems.length === 0 && pairs.every((pair) => pair.passes),
1443
+ tokensFound: true,
1444
+ pairsChecked: pairs.length,
1445
+ pairs,
1446
+ problems
1447
+ };
1448
+ };
1449
+ const emptyResult$1 = (tokensFound) => ({
1450
+ ok: true,
1451
+ tokensFound,
1452
+ pairsChecked: 0,
1453
+ pairs: [],
1454
+ problems: []
1455
+ });
1456
+ const collectTokens = (parsed) => {
1457
+ const out = /* @__PURE__ */ new Map();
1458
+ const walk = (node, tier, path) => {
1459
+ for (const [key, child] of Object.entries(node)) {
1460
+ if (key.startsWith("$") || !isRecord(child)) continue;
1461
+ const childPath = `${path}.${key}`;
1462
+ if ("$value" in child) out.set(childPath, {
1463
+ tier,
1464
+ value: child.$value,
1465
+ node: child
1466
+ });
1467
+ else walk(child, tier, childPath);
1468
+ }
1469
+ };
1470
+ for (const tier of TOKEN_TIERS) {
1471
+ const group = parsed[tier];
1472
+ if (isRecord(group)) walk(group, tier, tier);
1473
+ }
1474
+ return out;
1475
+ };
1476
+ const readExtension = (node, namespace) => {
1477
+ const extensions = node["$extensions"];
1478
+ return isRecord(extensions) ? extensions[namespace] : void 0;
1479
+ };
1480
+ const declaredModes = (tokens) => {
1481
+ const names = /* @__PURE__ */ new Set();
1482
+ for (const token of tokens.values()) {
1483
+ const modes = readExtension(token.node, MODES_EXTENSION);
1484
+ if (isRecord(modes)) {
1485
+ for (const name of Object.keys(modes)) if (name.trim().length > 0) names.add(name);
1486
+ }
1487
+ }
1488
+ return [...names].toSorted();
1489
+ };
1490
+ const discoverPairs = (tokens, problems) => {
1491
+ const merged = /* @__PURE__ */ new Map();
1492
+ const add = (spec) => {
1493
+ merged.set(`${spec.foreground}|${spec.background}`, spec);
1494
+ };
1495
+ for (const spec of conventionPairs(tokens)) add(spec);
1496
+ for (const spec of extensionPairs(tokens, problems)) add(spec);
1497
+ return [...merged.values()];
1498
+ };
1499
+ const conventionPairs = (tokens) => {
1500
+ const pairs = [];
1501
+ for (const [path, token] of tokens) {
1502
+ if (token.tier !== "semantic") continue;
1503
+ const segments = path.split(".");
1504
+ const leaf = segments[segments.length - 1] ?? "";
1505
+ if (!leaf.startsWith("on-") || leaf.length <= 3) continue;
1506
+ const basePath = [...segments.slice(0, -1), leaf.slice(3)].join(".");
1507
+ if (!tokens.has(basePath)) continue;
1508
+ if (!resolveColor(path, "base", tokens)) continue;
1509
+ if (!resolveColor(basePath, "base", tokens)) continue;
1510
+ pairs.push({
1511
+ foreground: path,
1512
+ background: basePath,
1513
+ level: DEFAULT_LEVEL,
1514
+ source: "convention"
1515
+ });
1516
+ }
1517
+ return pairs;
1518
+ };
1519
+ const extensionPairs = (tokens, problems) => {
1520
+ const pairs = [];
1521
+ for (const [path, token] of tokens) {
1522
+ const declaration = readExtension(token.node, CONTRAST_EXTENSION);
1523
+ if (declaration === void 0) continue;
1524
+ const entries = Array.isArray(declaration) ? declaration : [declaration];
1525
+ for (const entry of entries) {
1526
+ if (!isRecord(entry) || typeof entry["against"] !== "string") {
1527
+ problems.push(`token "${path}" has a malformed ${CONTRAST_EXTENSION} declaration — expected { against: "{token.path}", level? }.`);
1528
+ continue;
1529
+ }
1530
+ const against = aliasTarget(entry["against"]) ?? entry["against"];
1531
+ if (against === path) {
1532
+ problems.push(`token "${path}" declares contrast against itself — a pair needs two different tokens.`);
1533
+ continue;
1534
+ }
1535
+ if (!tokens.has(against)) {
1536
+ problems.push(`token "${path}" contrast-against "${entry["against"]}" does not resolve to a known token.`);
1537
+ continue;
1538
+ }
1539
+ if (!resolveColor(against, "base", tokens)) {
1540
+ problems.push(`token "${path}" contrast-against "${entry["against"]}" is not a colour token.`);
1541
+ continue;
1542
+ }
1543
+ if (!resolveColor(path, "base", tokens)) {
1544
+ problems.push(`token "${path}" declares a contrast pair but is not itself a colour token.`);
1545
+ continue;
1546
+ }
1547
+ pairs.push({
1548
+ foreground: path,
1549
+ background: against,
1550
+ level: normalizeLevel(entry["level"], path, problems),
1551
+ source: "extension"
1552
+ });
1553
+ }
1554
+ }
1555
+ return pairs;
1556
+ };
1557
+ const normalizeLevel = (raw, path, problems) => {
1558
+ if (raw === void 0) return DEFAULT_LEVEL;
1559
+ if (typeof raw === "string" && CONTRAST_LEVELS.includes(raw)) return raw;
1560
+ problems.push(`token "${path}" has an unknown contrast level "${String(raw)}" — use ${CONTRAST_LEVELS.join("/")}.`);
1561
+ return DEFAULT_LEVEL;
1562
+ };
1563
+ const measurePairs = (specs, modes, tokens) => {
1564
+ const rows = [];
1565
+ for (const spec of specs) {
1566
+ const baseFg = resolveColor(spec.foreground, BASE_MODE, tokens);
1567
+ const baseBg = resolveColor(spec.background, BASE_MODE, tokens);
1568
+ if (!baseFg || !baseBg) continue;
1569
+ rows.push(makeRow(spec, BASE_MODE, baseFg, baseBg));
1570
+ for (const mode of modes) {
1571
+ const fg = resolveColor(spec.foreground, mode, tokens) ?? baseFg;
1572
+ const bg = resolveColor(spec.background, mode, tokens) ?? baseBg;
1573
+ if (sameColor(fg, baseFg) && sameColor(bg, baseBg)) continue;
1574
+ rows.push(makeRow(spec, mode, fg, bg));
1575
+ }
1576
+ }
1577
+ return rows;
1578
+ };
1579
+ const makeRow = (spec, mode, foreground, background) => {
1580
+ const ratio = contrastRatio(foreground, background);
1581
+ const floor = WCAG_FLOORS[spec.level];
1582
+ return {
1583
+ foreground: spec.foreground,
1584
+ background: spec.background,
1585
+ mode,
1586
+ level: spec.level,
1587
+ ratio,
1588
+ floor,
1589
+ passes: ratio >= floor,
1590
+ source: spec.source
1591
+ };
1592
+ };
1593
+ const sameColor = (a, b) => a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
1594
+ const resolveColor = (path, mode, tokens, seen = /* @__PURE__ */ new Set()) => {
1595
+ if (seen.has(path) || seen.size >= 64) return void 0;
1596
+ seen.add(path);
1597
+ const token = tokens.get(path);
1598
+ if (!token) return void 0;
1599
+ let value = token.value;
1600
+ if (mode !== "base") {
1601
+ const modes = readExtension(token.node, MODES_EXTENSION);
1602
+ if (isRecord(modes) && modes[mode] !== void 0) value = modes[mode];
1603
+ }
1604
+ const alias = aliasTarget(value);
1605
+ if (alias !== void 0) return resolveColor(alias, mode, tokens, seen);
1606
+ return typeof value === "string" ? parseColor(value) : void 0;
1607
+ };
1608
+ //#endregion
1609
+ //#region src/design-tokens/design-sync.constant.ts
1610
+ const DESIGN_TOOL_EXTENSION = "com.lemony.design-tool";
1611
+ //#endregion
1612
+ //#region src/design-tokens/neutral-mapping.ts
1613
+ const KNOWN_TIERS = new Set(TOKEN_TIERS);
1614
+ const dtcgToNeutral = (parsed) => {
1615
+ const variables = [];
1616
+ for (const tier of TOKEN_TIERS) {
1617
+ const group = parsed[tier];
1618
+ if (isRecord(group)) walk(group, tier, variables);
1619
+ }
1620
+ return {
1621
+ schemaVersion: 1,
1622
+ variables: variables.toSorted(byName)
1623
+ };
1624
+ };
1625
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1626
+ const walk = (node, path, out) => {
1627
+ for (const [key, child] of Object.entries(node)) {
1628
+ if (key.startsWith("$") || !isRecord(child)) continue;
1629
+ const childPath = `${path}.${key}`;
1630
+ if ("$value" in child) out.push(toVariable$1(childPath, child));
1631
+ else walk(child, childPath, out);
1632
+ }
1633
+ };
1634
+ const toVariable$1 = (name, node) => {
1635
+ const type = typeof node["$type"] === "string" ? node["$type"] : "";
1636
+ const modes = readModes(node);
1637
+ const alias = aliasTarget(node["$value"]);
1638
+ const base = {
1639
+ name,
1640
+ type
1641
+ };
1642
+ if (modes) base.modes = modes;
1643
+ if (alias !== void 0) return {
1644
+ ...base,
1645
+ ref: alias
1646
+ };
1647
+ return {
1648
+ ...base,
1649
+ value: stringifyValue(node["$value"])
1650
+ };
1651
+ };
1652
+ const stringifyValue = (value) => value === void 0 || value === null ? "" : String(value);
1653
+ const readModes = (node) => {
1654
+ const extensions = node["$extensions"];
1655
+ const modes = isRecord(extensions) ? extensions[MODES_EXTENSION] : void 0;
1656
+ if (!isRecord(modes)) return void 0;
1657
+ const out = {};
1658
+ for (const [name, value] of Object.entries(modes)) if (name.trim().length > 0 && typeof value === "string") out[name] = value;
1659
+ return Object.keys(out).length > 0 ? out : void 0;
1660
+ };
1661
+ const toIncoming = (variable) => {
1662
+ const isAlias = variable.ref !== void 0;
1663
+ const value = isAlias ? `{${variable.ref}}` : variable.value ?? "";
1664
+ const type = variable.type;
1665
+ const first = variable.name.split(".")[0] ?? "";
1666
+ if (KNOWN_TIERS.has(first)) return withModes({
1667
+ path: variable.name,
1668
+ tier: first,
1669
+ type,
1670
+ value
1671
+ }, variable);
1672
+ const tier = isAlias ? "semantic" : "primitive";
1673
+ return withModes({
1674
+ path: `${tier}.${variable.name}`,
1675
+ tier,
1676
+ type,
1677
+ value
1678
+ }, variable);
1679
+ };
1680
+ const withModes = (token, variable) => variable.modes ? {
1681
+ ...token,
1682
+ modes: variable.modes
1683
+ } : token;
1684
+ const comparisonKey = (type, value, modes) => JSON.stringify({
1685
+ type,
1686
+ value,
1687
+ modes: sortedModes(modes)
1688
+ });
1689
+ const sortedModes = (modes) => {
1690
+ if (!modes) return null;
1691
+ const out = {};
1692
+ for (const name of Object.keys(modes).toSorted()) out[name] = modes[name] ?? "";
1693
+ return out;
1694
+ };
1695
+ const diffNeutralAgainstJson = (neutral, parsed) => {
1696
+ const current = /* @__PURE__ */ new Map();
1697
+ for (const variable of dtcgToNeutral(parsed).variables) current.set(variable.name, variable);
1698
+ const changes = [];
1699
+ for (const variable of neutral.variables) {
1700
+ const incoming = toIncoming(variable);
1701
+ const existing = current.get(incoming.path);
1702
+ const after = incoming.value;
1703
+ const before = existing ? neutralValue(existing) : void 0;
1704
+ changes.push({
1705
+ path: incoming.path,
1706
+ tier: incoming.tier,
1707
+ kind: classifyImport(existing, incoming, variable),
1708
+ ...before !== void 0 ? { before } : {},
1709
+ after
1710
+ });
1711
+ }
1712
+ return changes.toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1713
+ };
1714
+ const neutralValue = (variable) => variable.ref !== void 0 ? `{${variable.ref}}` : variable.value ?? "";
1715
+ const classifyImport = (existing, incoming, variable) => {
1716
+ if (!existing) return "new";
1717
+ return comparisonKey(existing.type, neutralValue(existing), existing.modes) === comparisonKey(incoming.type, incoming.value, variable.modes) ? "unchanged" : "changed";
1718
+ };
1719
+ const mergeNeutralIntoJson = (neutral, parsed, only) => {
1720
+ const merged = structuredClone(parsed);
1721
+ const onlySet = only ? new Set(only) : void 0;
1722
+ const written = [];
1723
+ const skipped = [];
1724
+ for (const variable of neutral.variables) {
1725
+ const incoming = toIncoming(variable);
1726
+ if (onlySet && !onlySet.has(incoming.path)) continue;
1727
+ (setToken(merged, incoming) ? written : skipped).push(incoming.path);
1728
+ }
1729
+ return {
1730
+ merged,
1731
+ written: written.toSorted(),
1732
+ skipped: skipped.toSorted()
1733
+ };
1734
+ };
1735
+ const isTokenNode = (value) => isRecord(value) && "$value" in value;
1736
+ const hasGroupChildren = (value) => isRecord(value) && Object.keys(value).some((key) => !key.startsWith("$"));
1737
+ const setToken = (root, incoming) => {
1738
+ const segments = incoming.path.split(".");
1739
+ const leafKey = segments[segments.length - 1];
1740
+ if (!leafKey) return false;
1741
+ let probe = root;
1742
+ for (const segment of segments.slice(0, -1)) {
1743
+ if (!isRecord(probe)) break;
1744
+ const next = probe[segment];
1745
+ if (isTokenNode(next)) return false;
1746
+ probe = next;
1747
+ }
1748
+ if (isRecord(probe) && hasGroupChildren(probe[leafKey])) return false;
1749
+ let node = root;
1750
+ for (const segment of segments.slice(0, -1)) {
1751
+ const next = node[segment];
1752
+ if (!isRecord(next)) node[segment] = {};
1753
+ node = node[segment];
1754
+ }
1755
+ const leaf = { $value: incoming.value };
1756
+ if (incoming.type.length > 0) leaf["$type"] = incoming.type;
1757
+ if (incoming.modes) leaf["$extensions"] = { [MODES_EXTENSION]: incoming.modes };
1758
+ node[leafKey] = leaf;
1759
+ return true;
1760
+ };
1761
+ const upsertPlan = (parsed, toolState) => {
1762
+ const current = /* @__PURE__ */ new Map();
1763
+ for (const variable of toolState?.variables ?? []) current.set(variable.name, variable);
1764
+ const plan = [];
1765
+ for (const variable of dtcgToNeutral(parsed).variables) plan.push({
1766
+ name: variable.name,
1767
+ kind: classifyExport(variable, current)
1768
+ });
1769
+ return plan;
1770
+ };
1771
+ const classifyExport = (projected, current) => {
1772
+ const existing = current.get(projected.name);
1773
+ if (!existing) return "create";
1774
+ return comparisonKey(projected.type, neutralValue(projected), projected.modes) === comparisonKey(existing.type, neutralValue(existing), existing.modes) ? "unchanged" : "update";
1775
+ };
1776
+ //#endregion
1777
+ //#region src/design-tokens/sync-io.ts
1778
+ const parseNeutralFile = (raw) => {
1779
+ let parsed;
1780
+ try {
1781
+ parsed = JSON.parse(raw);
1782
+ } catch (error) {
1783
+ throw new Error(`neutral file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1784
+ }
1785
+ if (!isRecord(parsed) || !Array.isArray(parsed["variables"])) throw new Error("neutral file must be a JSON object with a \"variables\" array.");
1786
+ const schemaVersion = typeof parsed["schemaVersion"] === "number" ? parsed["schemaVersion"] : 0;
1787
+ const provider = typeof parsed["provider"] === "string" ? parsed["provider"] : void 0;
1788
+ return {
1789
+ schemaVersion,
1790
+ ...provider ? { provider } : {},
1791
+ variables: parsed["variables"].flatMap(toVariable)
1792
+ };
1793
+ };
1794
+ const toVariable = (raw) => {
1795
+ if (!isRecord(raw) || typeof raw["name"] !== "string" || raw["name"].trim().length === 0) return [];
1796
+ const type = typeof raw["type"] === "string" ? raw["type"] : "";
1797
+ const variable = {
1798
+ name: raw["name"],
1799
+ type
1800
+ };
1801
+ if (typeof raw["ref"] === "string") variable.ref = raw["ref"];
1802
+ else if (typeof raw["value"] === "string") variable.value = raw["value"];
1803
+ const modes = readStringMap(raw["modes"]);
1804
+ if (modes) variable.modes = modes;
1805
+ return [variable];
1806
+ };
1807
+ const readStringMap = (raw) => {
1808
+ if (!isRecord(raw)) return void 0;
1809
+ const out = {};
1810
+ for (const [key, value] of Object.entries(raw)) if (key.trim().length > 0 && typeof value === "string") out[key] = value;
1811
+ return Object.keys(out).length > 0 ? out : void 0;
1812
+ };
1813
+ const readJsonObject = (raw) => {
1814
+ let parsed;
1815
+ try {
1816
+ parsed = JSON.parse(raw);
1817
+ } catch (error) {
1818
+ throw new Error(`${DESIGN_TOKENS_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — run \`design-tokens validate\` first.`, { cause: error });
1819
+ }
1820
+ if (!isRecord(parsed)) throw new Error(`${DESIGN_TOKENS_FILE} must be a JSON object — run \`design-tokens validate\` first.`);
1821
+ return parsed;
1822
+ };
1823
+ const writeJsonFile = (value) => `${JSON.stringify(value, null, 2)}\n`;
1824
+ const writeNeutralFile = (file) => writeJsonFile(file);
1825
+ //#endregion
1826
+ //#region src/design-tokens/import-tokens.ts
1827
+ const runImport = async (inputs) => {
1828
+ const empty = {
1829
+ tokensFound: false,
1830
+ changes: [],
1831
+ applied: false,
1832
+ written: [],
1833
+ skipped: [],
1834
+ problems: []
1835
+ };
1836
+ if (!await pathExists(inputs.neutralPath)) return {
1837
+ ...empty,
1838
+ problems: [`neutral file not found: ${inputs.neutralPath}`]
1839
+ };
1840
+ let neutral;
1841
+ try {
1842
+ neutral = parseNeutralFile(await readFile(inputs.neutralPath, "utf8"));
1843
+ } catch (error) {
1844
+ return {
1845
+ ...empty,
1846
+ problems: [asMessage$1(error)]
1847
+ };
1848
+ }
1849
+ if (neutral.schemaVersion !== 1) return {
1850
+ ...empty,
1851
+ problems: [`neutral file schemaVersion ${neutral.schemaVersion} is not supported (expected 1).`]
1852
+ };
1853
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1854
+ const tokensFound = await pathExists(tokenPath);
1855
+ let parsed;
1856
+ try {
1857
+ parsed = tokensFound ? readJsonObject(await readFile(tokenPath, "utf8")) : {};
1858
+ } catch (error) {
1859
+ return {
1860
+ ...empty,
1861
+ tokensFound,
1862
+ problems: [asMessage$1(error)]
1863
+ };
1864
+ }
1865
+ const changes = diffNeutralAgainstJson(neutral, parsed);
1866
+ if (!inputs.apply) return {
1867
+ tokensFound,
1868
+ changes,
1869
+ applied: false,
1870
+ written: [],
1871
+ skipped: [],
1872
+ problems: []
1873
+ };
1874
+ const { merged, written, skipped } = mergeNeutralIntoJson(neutral, parsed, inputs.only);
1875
+ await mkdir(dirname(tokenPath), { recursive: true });
1876
+ await writeFile(tokenPath, writeJsonFile(merged));
1877
+ return {
1878
+ tokensFound,
1879
+ changes,
1880
+ applied: true,
1881
+ written,
1882
+ skipped,
1883
+ problems: []
1884
+ };
1885
+ };
1886
+ const asMessage$1 = (error) => error instanceof Error ? error.message : String(error);
1887
+ //#endregion
1888
+ //#region src/design-tokens/drift.ts
1889
+ const readBinding = (parsed) => {
1890
+ const extensions = parsed["$extensions"];
1891
+ const binding = isRecord(extensions) ? extensions[DESIGN_TOOL_EXTENSION] : void 0;
1892
+ if (!isRecord(binding)) return void 0;
1893
+ const provider = binding["provider"];
1894
+ if (typeof provider !== "string" || provider.trim().length === 0) return;
1895
+ const lastProjected = binding["lastProjected"];
1896
+ return {
1897
+ provider,
1898
+ ...typeof lastProjected === "string" ? { lastProjected } : {}
1899
+ };
1900
+ };
1901
+ const projectionHash = (parsed) => {
1902
+ const canonical = JSON.stringify(dtcgToNeutral(parsed).variables.map(canonicalVariable));
1903
+ return createHash("sha256").update(canonical).digest("hex");
1904
+ };
1905
+ const canonicalVariable = (variable) => ({
1906
+ name: variable.name,
1907
+ type: variable.type,
1908
+ value: variable.value ?? null,
1909
+ ref: variable.ref ?? null,
1910
+ modes: variable.modes ? Object.fromEntries(Object.keys(variable.modes).toSorted().map((name) => [name, variable.modes?.[name] ?? ""])) : null
1911
+ });
1912
+ const driftReport = (parsed) => {
1913
+ const binding = readBinding(parsed);
1914
+ if (!binding) return {
1915
+ tokensFound: true,
1916
+ provider: void 0,
1917
+ state: "n/a"
1918
+ };
1919
+ if (binding.lastProjected === void 0) return {
1920
+ tokensFound: true,
1921
+ provider: binding.provider,
1922
+ state: "never-projected"
1923
+ };
1924
+ const state = projectionHash(parsed) === binding.lastProjected ? "in-sync" : "export-pending";
1925
+ return {
1926
+ tokensFound: true,
1927
+ provider: binding.provider,
1928
+ state
1929
+ };
1930
+ };
1931
+ //#endregion
1932
+ //#region src/design-tokens/export-tokens.ts
1933
+ const runExport = async (inputs) => {
1934
+ const empty = {
1935
+ tokensFound: false,
1936
+ plan: [],
1937
+ hash: "",
1938
+ recorded: false,
1939
+ problems: []
1940
+ };
1941
+ const tokenPath = join(inputs.repoRoot, DESIGN_TOKENS_FILE);
1942
+ if (!await pathExists(tokenPath)) return empty;
1943
+ let parsed;
1944
+ try {
1945
+ parsed = readJsonObject(await readFile(tokenPath, "utf8"));
1946
+ } catch (error) {
1947
+ return {
1948
+ ...empty,
1949
+ tokensFound: true,
1950
+ problems: [asMessage(error)]
1951
+ };
1952
+ }
1953
+ const binding = readBinding(parsed);
1954
+ if (!binding) return {
1955
+ ...empty,
1956
+ tokensFound: true,
1957
+ problems: [`no design tool is declared — add a "${DESIGN_TOOL_EXTENSION}" provider to ${DESIGN_TOKENS_FILE} before exporting.`]
1958
+ };
1959
+ const hash = projectionHash(parsed);
1960
+ let toolState;
1961
+ if (inputs.toolStatePath !== void 0) {
1962
+ if (!await pathExists(inputs.toolStatePath)) return {
1963
+ ...empty,
1964
+ tokensFound: true,
1965
+ hash,
1966
+ problems: [`tool-state file not found: ${inputs.toolStatePath}`]
1967
+ };
1968
+ try {
1969
+ toolState = parseNeutralFile(await readFile(inputs.toolStatePath, "utf8"));
1970
+ } catch (error) {
1971
+ return {
1972
+ ...empty,
1973
+ tokensFound: true,
1974
+ hash,
1975
+ problems: [asMessage(error)]
1976
+ };
1977
+ }
1978
+ }
1979
+ const plan = upsertPlan(parsed, toolState);
1980
+ if (inputs.outPath !== void 0) {
1981
+ const projection = dtcgToNeutral(parsed);
1982
+ projection.provider = binding.provider;
1983
+ await writeFile(inputs.outPath, writeNeutralFile(projection));
1984
+ }
1985
+ if (inputs.record) {
1986
+ await writeFile(tokenPath, writeJsonFile(stampBaseline(parsed, hash)));
1987
+ return {
1988
+ tokensFound: true,
1989
+ plan,
1990
+ hash,
1991
+ recorded: true,
1992
+ problems: []
1993
+ };
1994
+ }
1995
+ return {
1996
+ tokensFound: true,
1997
+ plan,
1998
+ hash,
1999
+ recorded: false,
2000
+ problems: []
2001
+ };
2002
+ };
2003
+ const stampBaseline = (parsed, hash) => {
2004
+ const clone = structuredClone(parsed);
2005
+ const extensions = isRecord(clone["$extensions"]) ? { ...clone["$extensions"] } : {};
2006
+ const binding = isRecord(extensions["com.lemony.design-tool"]) ? { ...extensions[DESIGN_TOOL_EXTENSION] } : {};
2007
+ binding["lastProjected"] = hash;
2008
+ extensions[DESIGN_TOOL_EXTENSION] = binding;
2009
+ clone["$extensions"] = extensions;
2010
+ return clone;
2011
+ };
2012
+ const asMessage = (error) => error instanceof Error ? error.message : String(error);
2013
+ //#endregion
1301
2014
  //#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
2015
  const MANAGED_LABEL = "harness:managed";
1309
- /** It enters at `pending` — the fit level (L1/L2/L3) is decided at pickup, not capture. */
1310
2016
  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
2017
  const ARCHITECTURE_DRIFT_LABEL = "harness:architecture-drift";
1317
2018
  //#endregion
1318
2019
  //#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
2020
  const SPINOFF_KINDS = ["architecture-drift"];
1327
2021
  //#endregion
1328
2022
  //#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
2023
  const runSpinoff = async (inputs) => {
1351
2024
  const title = requireFlag(inputs.title, "title");
1352
2025
  const severity = parseSeverity(inputs.severity);
@@ -1410,15 +2083,9 @@ const runSpinoff = async (inputs) => {
1410
2083
  labelSync
1411
2084
  };
1412
2085
  };
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
2086
  const composeBody = (body, parentId) => {
1419
2087
  return [body?.trim(), parentId !== null ? `Discovered during #${parentId}` : ""].filter(Boolean).join("\n\n");
1420
2088
  };
1421
- /** Parse the trailing issue number from a `gh issue create` URL (…/issues/<n>). */
1422
2089
  const parseIssueId = (url) => url.match(/\/issues\/(\d+)\b/)?.[1];
1423
2090
  const requireFlag = (value, name) => {
1424
2091
  const trimmed = value?.trim();
@@ -1435,10 +2102,6 @@ const parseKind = (raw) => {
1435
2102
  if (SPINOFF_KINDS.includes(raw)) return raw;
1436
2103
  throw new Error(`spinoff: --kind must be one of ${SPINOFF_KINDS.join("|")} (got "${raw}").`);
1437
2104
  };
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
2105
  const kindLabels = (kind) => {
1443
2106
  const labels = [MANAGED_LABEL, PENDING_STATUS_LABEL];
1444
2107
  if (kind === "architecture-drift") labels.push(ARCHITECTURE_DRIFT_LABEL);
@@ -1451,18 +2114,6 @@ const failureReason = (result) => {
1451
2114
  };
1452
2115
  //#endregion
1453
2116
  //#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
2117
  const ENVELOPE_AXIS = {
1467
2118
  type: "internal-enum",
1468
2119
  ts: "metric",
@@ -1528,13 +2179,6 @@ const FIELD_AXIS = {
1528
2179
  attributed_name: "internal-enum"
1529
2180
  }
1530
2181
  };
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
2182
  const KEEP_BY_TIER = {
1539
2183
  anonymous: /* @__PURE__ */ new Set(["internal-enum", "metric"]),
1540
2184
  project: /* @__PURE__ */ new Set([
@@ -1546,17 +2190,6 @@ const KEEP_BY_TIER = {
1546
2190
  };
1547
2191
  //#endregion
1548
2192
  //#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
2193
  const sanitizeLine = (raw, tier) => {
1561
2194
  let parsed;
1562
2195
  try {
@@ -1578,67 +2211,16 @@ const sanitizeLine = (raw, tier) => {
1578
2211
  };
1579
2212
  //#endregion
1580
2213
  //#region src/telemetry/telemetry.constant.ts
1581
- /** The append-only event log the cursor walks (ADR 0008). */
1582
2214
  const EVENTS_RELPATH = join(".claude", "state", "events.jsonl");
1583
- /** Byte-offset watermark of what has been delivered (D5, gitignored). */
1584
2215
  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
2216
  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
2217
  const PAYLOAD_CAP_BYTES = 1e6;
1600
- /** Per-request hard timeout — the send must never stall a hook (D4). */
1601
2218
  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
2219
  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
2220
  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
2221
  const TELEMETRY_ENDPOINT = "https://lemony-telemetry.lemoncode.workers.dev";
1623
2222
  //#endregion
1624
2223
  //#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
2224
  const chunkTail = (rawTail, tier, capBytes = PAYLOAD_CAP_BYTES) => {
1643
2225
  const parts = rawTail.split("\n");
1644
2226
  const segments = [];
@@ -1693,20 +2275,7 @@ const chunkTail = (rawTail, tier, capBytes = PAYLOAD_CAP_BYTES) => {
1693
2275
  };
1694
2276
  //#endregion
1695
2277
  //#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
2278
  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
2279
  const ENV_DISABLE_VALUES = [
1711
2280
  "1",
1712
2281
  "true",
@@ -1714,23 +2283,12 @@ const ENV_DISABLE_VALUES = [
1714
2283
  ];
1715
2284
  //#endregion
1716
2285
  //#region src/telemetry/consent.ts
1717
- /** The env var disables only on its truthy allow-list; everything else is inert. */
1718
2286
  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
2287
  const isDoNotTrackSet = (value) => {
1725
2288
  if (value === void 0) return false;
1726
2289
  const trimmed = value.trim();
1727
2290
  return trimmed !== "" && trimmed !== "0";
1728
2291
  };
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
2292
  const isLocallyOptedOut = async (repoRoot) => {
1735
2293
  try {
1736
2294
  const raw = await readFile(join(repoRoot, CONSENT_RELPATH), "utf8");
@@ -1739,22 +2297,6 @@ const isLocallyOptedOut = async (repoRoot) => {
1739
2297
  return false;
1740
2298
  }
1741
2299
  };
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
2300
  const resolveConsent = async ({ repoRoot, env: env$4 = env }) => {
1759
2301
  if (isDoNotTrackSet(env$4["DO_NOT_TRACK"])) return {
1760
2302
  enabled: false,
@@ -1786,20 +2328,9 @@ const resolveConsent = async ({ repoRoot, env: env$4 = env }) => {
1786
2328
  };
1787
2329
  //#endregion
1788
2330
  //#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
2331
  const contentHash = (bytes) => createHash("sha256").update(bytes).digest("hex");
1796
2332
  //#endregion
1797
2333
  //#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
2334
  const readCursor = async (repoRoot) => {
1804
2335
  try {
1805
2336
  const raw = await readFile(join(repoRoot, CURSOR_RELPATH), "utf8");
@@ -1815,7 +2346,6 @@ const readCursor = async (repoRoot) => {
1815
2346
  };
1816
2347
  }
1817
2348
  };
1818
- /** Persist the cursor (pretty-printed JSON), creating parent dirs as needed. */
1819
2349
  const writeCursor = async (repoRoot, cursor) => {
1820
2350
  const path = join(repoRoot, CURSOR_RELPATH);
1821
2351
  await mkdir(dirname(path), { recursive: true });
@@ -1825,30 +2355,12 @@ const writeCursor = async (repoRoot, cursor) => {
1825
2355
  //#region src/telemetry/prune.ts
1826
2356
  const stateDirOf = (repoRoot) => join(repoRoot, dirname(EVENTS_RELPATH));
1827
2357
  const eventsPathOf = (repoRoot) => join(repoRoot, EVENTS_RELPATH);
1828
- /** A drain file is `events.draining.<id>` (its `.meta` sidecar is excluded). */
1829
2358
  const isDrainFile = (name) => name.startsWith(`events.draining.`) && !name.endsWith(".meta");
1830
2359
  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
2360
  const trustedOffset = (buf, sentOffset) => {
1839
2361
  if (!Number.isFinite(sentOffset) || sentOffset <= 0 || sentOffset > buf.length) return 0;
1840
2362
  return buf[sentOffset - 1] === 10 ? sentOffset : 0;
1841
2363
  };
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
2364
  const replayOverflow = async (eventsPath, overflow) => {
1853
2365
  const handle = await open(eventsPath, "a");
1854
2366
  try {
@@ -1867,7 +2379,6 @@ const replayOverflow = async (eventsPath, overflow) => {
1867
2379
  await handle.close();
1868
2380
  }
1869
2381
  };
1870
- /** Replay one drain file's unsent tail into `events.jsonl`, then delete it + its meta. */
1871
2382
  const drainOne = async (stateDir, drainName) => {
1872
2383
  const drainPath = join(stateDir, drainName);
1873
2384
  const metaPath = `${drainPath}.meta`;
@@ -1892,13 +2403,6 @@ const drainOne = async (stateDir, drainName) => {
1892
2403
  await rm(metaPath, { force: true });
1893
2404
  return buf.byteLength - from;
1894
2405
  };
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
2406
  const recoverDrainings = async (repoRoot) => {
1903
2407
  const stateDir = stateDirOf(repoRoot);
1904
2408
  let entries;
@@ -1911,23 +2415,6 @@ const recoverDrainings = async (repoRoot) => {
1911
2415
  await Promise.all(entries.filter(isDrainMeta).filter((meta) => !entries.includes(meta.slice(0, -5))).map((meta) => rm(join(stateDir, meta), { force: true }).catch(() => {})));
1912
2416
  return { recovered };
1913
2417
  };
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
2418
  const prunePrefix = async (repoRoot, options = {}) => {
1932
2419
  const { thresholdBytes = PRUNE_THRESHOLD_BYTES } = options;
1933
2420
  try {
@@ -1967,7 +2454,6 @@ const prunePrefix = async (repoRoot, options = {}) => {
1967
2454
  return { prunedBytes: 0 };
1968
2455
  }
1969
2456
  };
1970
- /** Remove every `events.draining.*` (drain + meta) — used by `disable --purge-local`. */
1971
2457
  const purgeDrainings = async (repoRoot) => {
1972
2458
  const stateDir = stateDirOf(repoRoot);
1973
2459
  let entries;
@@ -1980,16 +2466,6 @@ const purgeDrainings = async (repoRoot) => {
1980
2466
  };
1981
2467
  //#endregion
1982
2468
  //#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
2469
  const appendRejects = async (repoRoot, rejects, now) => {
1994
2470
  if (rejects.length === 0) return 0;
1995
2471
  const path = join(repoRoot, REJECTED_RELPATH);
@@ -2007,7 +2483,6 @@ const appendRejects = async (repoRoot, rejects, now) => {
2007
2483
  };
2008
2484
  //#endregion
2009
2485
  //#region src/telemetry/send-telemetry.ts
2010
- /** A result with the wire counters zeroed — the no-op / early-return baseline. */
2011
2486
  const emptyResult = (outcome, extra = {}) => ({
2012
2487
  outcome,
2013
2488
  segmentsSent: 0,
@@ -2017,35 +2492,6 @@ const emptyResult = (outcome, extra = {}) => ({
2017
2492
  keys: [],
2018
2493
  ...extra
2019
2494
  });
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
2495
  const sendTelemetry = async (options) => {
2050
2496
  const { repoRoot, endpoint = TELEMETRY_ENDPOINT, env, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => /* @__PURE__ */ new Date(), capBytes = PAYLOAD_CAP_BYTES, pruneThresholdBytes } = options;
2051
2497
  const consent = await resolveConsent({
@@ -2146,37 +2592,14 @@ const sendTelemetry = async (options) => {
2146
2592
  };
2147
2593
  //#endregion
2148
2594
  //#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
2595
  const disableLocally = async (repoRoot) => {
2162
2596
  const path = join(repoRoot, CONSENT_RELPATH);
2163
2597
  await mkdir(dirname(path), { recursive: true });
2164
2598
  await writeFile(path, `${JSON.stringify({ disabled: true }, null, 2)}\n`);
2165
2599
  };
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
2600
  const enableLocally = async (repoRoot) => {
2172
2601
  await rm(join(repoRoot, CONSENT_RELPATH), { force: true });
2173
2602
  };
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
2603
  const purgeLocal = async (repoRoot) => {
2181
2604
  await rm(join(repoRoot, EVENTS_RELPATH), { force: true });
2182
2605
  await rm(join(repoRoot, CURSOR_RELPATH), { force: true });
@@ -2184,13 +2607,6 @@ const purgeLocal = async (repoRoot) => {
2184
2607
  };
2185
2608
  //#endregion
2186
2609
  //#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
2610
  const SOURCE_LABEL = {
2195
2611
  default: "default (on)",
2196
2612
  "do-not-track": "the DO_NOT_TRACK environment variable (cross-tool standard)",
@@ -2200,13 +2616,6 @@ const SOURCE_LABEL = {
2200
2616
  };
2201
2617
  //#endregion
2202
2618
  //#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
2619
  const buildTelemetryStatus = async ({ repoRoot, env: env$3 = env }) => {
2211
2620
  const { enabled, source } = await resolveConsent({
2212
2621
  repoRoot,
@@ -2220,7 +2629,6 @@ const buildTelemetryStatus = async ({ repoRoot, env: env$3 = env }) => {
2220
2629
  lastSentTs: cursor.last_sent_ts
2221
2630
  };
2222
2631
  };
2223
- /** Render the status report as terse, human lines (one per `console.log`). */
2224
2632
  const formatTelemetryStatus = (report) => {
2225
2633
  const headline = report.enabled ? "Telemetry: ON (anonymous)" : "Telemetry: OFF";
2226
2634
  const lastSend = report.lastSentTs === "" ? "never" : report.lastSentTs;
@@ -2232,12 +2640,6 @@ const formatTelemetryStatus = (report) => {
2232
2640
  };
2233
2641
  //#endregion
2234
2642
  //#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
2643
  const buildTelemetryShow = async (repoRoot) => {
2242
2644
  let raw;
2243
2645
  try {
@@ -2257,11 +2659,6 @@ const buildTelemetryShow = async (repoRoot) => {
2257
2659
  total: events.length
2258
2660
  };
2259
2661
  };
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
2662
  const formatTelemetryShow = (report) => {
2266
2663
  if (report.total === 0) return ["No local telemetry events (.claude/state/events.jsonl is empty or absent)."];
2267
2664
  const lines = [`${report.total} local event(s).`, ""];
@@ -2274,23 +2671,10 @@ const formatTelemetryShow = (report) => {
2274
2671
  };
2275
2672
  //#endregion
2276
2673
  //#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
2674
  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
2675
  const NOTICE_MESSAGE = "Anonymous telemetry is ON — run `lemony telemetry disable` to opt out. See PRIVACY.md.";
2291
2676
  //#endregion
2292
2677
  //#region src/telemetry/telemetry-notice.ts
2293
- /** Read the acknowledged fingerprint; a missing/unreadable sentinel reads as `''`. */
2294
2678
  const readSentinel = async (repoRoot) => {
2295
2679
  try {
2296
2680
  return (await readFile(join(repoRoot, NOTICE_SENTINEL_RELPATH), "utf8")).trim();
@@ -2298,19 +2682,11 @@ const readSentinel = async (repoRoot) => {
2298
2682
  return "";
2299
2683
  }
2300
2684
  };
2301
- /** Persist the acknowledged fingerprint, creating `.claude/state/` if needed. */
2302
2685
  const writeSentinel = async (repoRoot, fingerprint) => {
2303
2686
  const path = join(repoRoot, NOTICE_SENTINEL_RELPATH);
2304
2687
  await mkdir(dirname(path), { recursive: true });
2305
2688
  await writeFile(path, `${fingerprint}\n`);
2306
2689
  };
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
2690
  const resolveNotice = async ({ repoRoot, env: env$1 = env }) => {
2315
2691
  const { enabled, source } = await resolveConsent({
2316
2692
  repoRoot,
@@ -2328,13 +2704,6 @@ const resolveNotice = async ({ repoRoot, env: env$1 = env }) => {
2328
2704
  message: NOTICE_MESSAGE
2329
2705
  };
2330
2706
  };
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
2707
  const runNotice = async ({ repoRoot, env: env$2 = env, print }) => {
2339
2708
  const decision = await resolveNotice({
2340
2709
  repoRoot,
@@ -2348,13 +2717,6 @@ const runNotice = async ({ repoRoot, env: env$2 = env, print }) => {
2348
2717
  };
2349
2718
  //#endregion
2350
2719
  //#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
2720
  const formatTelemetryFlush = (result) => {
2359
2721
  if (result.outcome === "disabled") return [`Telemetry is off${result.source ? ` (source: ${result.source})` : ""} — nothing was sent.`];
2360
2722
  const reclaimed = result.prunedBytes !== void 0 && result.prunedBytes > 0 ? ` reclaimed: ${result.prunedBytes} byte(s) of confirmed-sent events` : void 0;
@@ -2376,11 +2738,6 @@ const formatTelemetryFlush = (result) => {
2376
2738
  };
2377
2739
  //#endregion
2378
2740
  //#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
2741
  const ANSI = {
2385
2742
  green: "\x1B[32m",
2386
2743
  yellow: "\x1B[33m",
@@ -2391,23 +2748,12 @@ const ANSI = {
2391
2748
  };
2392
2749
  //#endregion
2393
2750
  //#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
2751
  const colorsEnabled = (options) => {
2401
2752
  const noColor = options.env["NO_COLOR"];
2402
2753
  return options.isTty && (noColor === void 0 || noColor === "");
2403
2754
  };
2404
2755
  const wrap = (code) => (text) => `${code}${text}${ANSI.reset}`;
2405
2756
  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
2757
  const buildPalette = (enabled) => enabled ? {
2412
2758
  ok: wrap(ANSI.green),
2413
2759
  warn: wrap(ANSI.yellow),
@@ -2423,123 +2769,8 @@ const buildPalette = (enabled) => enabled ? {
2423
2769
  };
2424
2770
  //#endregion
2425
2771
  //#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
2772
  const LEMONY_PACKAGE = "@lemoncode/lemony";
2431
2773
  //#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
2774
  //#region src/install/devdependency.ts
2544
2775
  const inspectDevDependency = async (repoRoot) => {
2545
2776
  const pkgPath = join(repoRoot, "package.json");
@@ -2553,36 +2784,12 @@ const inspectDevDependency = async (repoRoot) => {
2553
2784
  };
2554
2785
  //#endregion
2555
2786
  //#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
2787
  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
2788
  const MUTATION_SCRIPT_NAME = "test:mutation";
2576
2789
  //#endregion
2577
2790
  //#region src/scan/scan.ts
2578
2791
  const ORIGIN_URL = /\[remote "origin"\][^[]*?url\s*=\s*(\S+)/;
2579
2792
  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
2793
  const parseOriginSlug = (gitConfig) => {
2587
2794
  const url = ORIGIN_URL.exec(gitConfig)?.[1];
2588
2795
  if (!url) return null;
@@ -2592,13 +2799,6 @@ const parseOriginSlug = (gitConfig) => {
2592
2799
  if (!owner || !name) return null;
2593
2800
  return `${owner}/${name}`;
2594
2801
  };
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
2802
  const readOriginSlug = async (root) => {
2603
2803
  try {
2604
2804
  return parseOriginSlug(await readFile(join(root, ".git", "config"), "utf8"));
@@ -2606,11 +2806,6 @@ const readOriginSlug = async (root) => {
2606
2806
  return null;
2607
2807
  }
2608
2808
  };
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
2809
  const hasMutationScript = async (root) => {
2615
2810
  try {
2616
2811
  const script = JSON.parse(await readFile(join(root, "package.json"), "utf8")).scripts?.[MUTATION_SCRIPT_NAME];
@@ -2619,7 +2814,6 @@ const hasMutationScript = async (root) => {
2619
2814
  return false;
2620
2815
  }
2621
2816
  };
2622
- /** Detect the repo capabilities the installer needs to render templates and gate skills. */
2623
2817
  const scanRepo = async (root) => {
2624
2818
  const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc, hasMutationTesting] = await Promise.all([
2625
2819
  pathExists(join(root, ".git")),
@@ -2643,23 +2837,14 @@ const scanRepo = async (root) => {
2643
2837
  };
2644
2838
  //#endregion
2645
2839
  //#region src/skills/frontmatter.ts
2646
- /** Leading `---\n … \n---` block at the very start of a markdown document. */
2647
2840
  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
2841
  const FIELD_LINE = /^([\w-]+)\s*:\s*(.*)$/;
2650
- /** Parse a `[a, b, c]` inline list, or return the raw scalar untouched. */
2651
2842
  const parseValue = (raw) => {
2652
2843
  if (!(raw.startsWith("[") && raw.endsWith("]"))) return raw;
2653
2844
  const inner = raw.slice(1, -1).trim();
2654
2845
  if (inner === "") return [];
2655
2846
  return inner.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
2656
2847
  };
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
2848
  const parseFrontmatter = (content) => {
2664
2849
  const block = FRONTMATTER_BLOCK$1.exec(content)?.[1];
2665
2850
  if (block === void 0) return {};
@@ -2675,7 +2860,6 @@ const parseFrontmatter = (content) => {
2675
2860
  };
2676
2861
  //#endregion
2677
2862
  //#region src/skills/skills.constant.ts
2678
- /** Human label for each phase, used when rendering the `{{SKILLS}}` block. */
2679
2863
  const PHASE_LABELS = {
2680
2864
  "pre-implementation": "Pre-implementation",
2681
2865
  "during-implementation": "During implementation",
@@ -2683,11 +2867,6 @@ const PHASE_LABELS = {
2683
2867
  };
2684
2868
  //#endregion
2685
2869
  //#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
2870
  const PHASES = [
2692
2871
  "pre-implementation",
2693
2872
  "during-implementation",
@@ -2695,14 +2874,6 @@ const PHASES = [
2695
2874
  ];
2696
2875
  //#endregion
2697
2876
  //#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
2877
  const CAPABILITY_REGISTRY = {
2707
2878
  "has-architecture-doc": {
2708
2879
  predicate: (caps) => caps.hasArchitectureDoc,
@@ -2715,12 +2886,6 @@ const CAPABILITY_REGISTRY = {
2715
2886
  label: "check test strength with mutation testing in review"
2716
2887
  }
2717
2888
  };
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
2889
  const capabilityHolds = (key, caps) => {
2725
2890
  const entry = CAPABILITY_REGISTRY[key];
2726
2891
  if (!entry) throw new Error(`unknown applies-when capability key "${key}"`);
@@ -2729,17 +2894,11 @@ const capabilityHolds = (key, caps) => {
2729
2894
  const asString = (value) => typeof value === "string" ? value : void 0;
2730
2895
  const asList = (value) => Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
2731
2896
  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
2897
  const readEnumField = (skillName, field, value, isValid) => {
2738
2898
  if (value === void 0) return void 0;
2739
2899
  if (typeof value !== "string" || !isValid(value)) throw new Error(`skill "${skillName}": invalid ${field} ${JSON.stringify(value)}`);
2740
2900
  return value;
2741
2901
  };
2742
- /** Build a `SkillMeta` from a skill's parsed frontmatter, validating enums. */
2743
2902
  const toSkillMeta = (name, frontmatter) => ({
2744
2903
  name,
2745
2904
  phase: readEnumField(name, "phase", frontmatter.phase, isPhase),
@@ -2747,11 +2906,6 @@ const toSkillMeta = (name, frontmatter) => ({
2747
2906
  appliesWhen: asList(frontmatter["applies-when"]),
2748
2907
  triggerCondition: asString(frontmatter["trigger-condition"])
2749
2908
  });
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
2909
  const readCatalogSkills = async (catalogRoot) => {
2756
2910
  const skillsRoot = join(catalogRoot, "skills");
2757
2911
  const entries = await readdir(skillsRoot, { withFileTypes: true });
@@ -2760,7 +2914,6 @@ const readCatalogSkills = async (catalogRoot) => {
2760
2914
  return toSkillMeta(entry.name, parseFrontmatter(content));
2761
2915
  }))).toSorted((a, b) => a.name.localeCompare(b.name));
2762
2916
  };
2763
- /** Whether every `applies-when` key of a skill holds for the scanned repo. */
2764
2917
  const appliesSatisfied = (skill, caps) => skill.appliesWhen.every((key) => {
2765
2918
  try {
2766
2919
  return capabilityHolds(key, caps);
@@ -2768,25 +2921,7 @@ const appliesSatisfied = (skill, caps) => skill.appliesWhen.every((key) => {
2768
2921
  throw new Error(`skill "${skill.name}": ${error.message}`, { cause: error });
2769
2922
  }
2770
2923
  });
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
2924
  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
2925
  const latentCapabilities = (skills, capabilities) => Object.entries(CAPABILITY_REGISTRY).toSorted(([a], [b]) => a.localeCompare(b)).filter(([, entry]) => !entry.predicate(capabilities)).flatMap(([key, entry]) => {
2791
2926
  const unlocked = skills.filter((skill) => skill.appliesWhen.includes(key) && skill.appliesWhen.every((other) => {
2792
2927
  if (other === key) return true;
@@ -2805,12 +2940,6 @@ const latentCapabilities = (skills, capabilities) => Object.entries(CAPABILITY_R
2805
2940
  }];
2806
2941
  });
2807
2942
  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
2943
  const renderRoleSkills = (selected, role) => {
2815
2944
  const roleSkills = selected.filter((skill) => skill.invokedBy.includes(role));
2816
2945
  const groups = [];
@@ -2825,13 +2954,9 @@ const renderRoleSkills = (selected, role) => {
2825
2954
  };
2826
2955
  //#endregion
2827
2956
  //#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
2957
  const LEMONY_BIN = "lemony";
2831
2958
  //#endregion
2832
2959
  //#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
2960
  const isExecutable = async (path) => {
2836
2961
  try {
2837
2962
  await access(path, constants.X_OK);
@@ -2840,19 +2965,6 @@ const isExecutable = async (path) => {
2840
2965
  return false;
2841
2966
  }
2842
2967
  };
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
2968
  const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) => {
2857
2969
  if (!pathEnv) return false;
2858
2970
  for (const dir of pathEnv.split(delimiter)) {
@@ -2863,26 +2975,12 @@ const isCliOnPath = async (pathEnv = process.env.PATH, isExe = isExecutable) =>
2863
2975
  };
2864
2976
  //#endregion
2865
2977
  //#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
2978
  const VENDOR_HOOK_COMMANDS = [
2872
2979
  ".claude/hooks/init.sh",
2873
2980
  ".claude/hooks/session-close.sh",
2874
2981
  ".claude/hooks/require-playbook.sh",
2875
2982
  ".claude/hooks/suggest-playbook.sh"
2876
2983
  ];
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
2984
  const runDoctor = async (deps) => {
2887
2985
  const { repoRoot } = deps;
2888
2986
  const checks = [];
@@ -2909,6 +3007,7 @@ const runDoctor = async (deps) => {
2909
3007
  checks.push(checkVersionPin(deps, config));
2910
3008
  checks.push(await checkCliResolution(deps));
2911
3009
  checks.push(await checkCapabilities(deps, config));
3010
+ checks.push(await checkDesignToolDrift(deps));
2912
3011
  return {
2913
3012
  checks,
2914
3013
  ok: checks.every((check) => check.status !== "fail")
@@ -3066,18 +3165,6 @@ const checkVersionPin = (deps, config) => {
3066
3165
  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
3166
  };
3068
3167
  };
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
3168
  const checkCliResolution = async (deps) => {
3082
3169
  const name = "cli-resolution";
3083
3170
  if (await isExecutable(join(deps.repoRoot, "node_modules", ".bin", "lemony"))) return {
@@ -3106,21 +3193,6 @@ const checkCliResolution = async (deps) => {
3106
3193
  remediation: `Install \`${LEMONY_BIN}\` globally and keep it on PATH (this repo has no package.json to pin a devDependency).`
3107
3194
  };
3108
3195
  };
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
3196
  const checkCapabilities = async (deps, config) => {
3125
3197
  const name = "capabilities";
3126
3198
  if (!config) return {
@@ -3150,20 +3222,63 @@ const checkCapabilities = async (deps, config) => {
3150
3222
  name,
3151
3223
  status: "info",
3152
3224
  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)."
3225
+ 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
3226
  };
3155
3227
  };
3228
+ const checkDesignToolDrift = async (deps) => {
3229
+ const name = "design-tool-drift";
3230
+ const tokenPath = join(deps.repoRoot, DESIGN_TOKENS_FILE);
3231
+ if (!await pathExists(tokenPath)) return {
3232
+ name,
3233
+ status: "info",
3234
+ detail: `No ${DESIGN_TOKENS_FILE} — token sync not in use.`
3235
+ };
3236
+ let parsed;
3237
+ try {
3238
+ parsed = JSON.parse(await readFile(tokenPath, "utf8"));
3239
+ } catch (error) {
3240
+ return {
3241
+ name,
3242
+ status: "warn",
3243
+ detail: `Could not read ${DESIGN_TOKENS_FILE}: ${error.message}`,
3244
+ remediation: "Run `lemony design-tokens validate` to find the problem."
3245
+ };
3246
+ }
3247
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
3248
+ name,
3249
+ status: "warn",
3250
+ detail: `${DESIGN_TOKENS_FILE} is not a JSON object.`,
3251
+ remediation: "Run `lemony design-tokens validate` to find the problem."
3252
+ };
3253
+ const report = driftReport(parsed);
3254
+ switch (report.state) {
3255
+ case "n/a": return {
3256
+ name,
3257
+ status: "info",
3258
+ detail: "No design tool declared — token sync not in use."
3259
+ };
3260
+ case "never-projected": return {
3261
+ name,
3262
+ status: "info",
3263
+ detail: `Design tool "${report.provider}" declared; no projection baseline yet.`,
3264
+ remediation: "Run /sync-design-tokens export in Claude Code to bootstrap the tool."
3265
+ };
3266
+ case "in-sync": return {
3267
+ name,
3268
+ status: "ok",
3269
+ detail: `Tokens are in sync with the "${report.provider}" design tool.`
3270
+ };
3271
+ case "export-pending": return {
3272
+ name,
3273
+ status: "warn",
3274
+ detail: `Tokens changed since the last projection to "${report.provider}" — an export is pending.`,
3275
+ remediation: "Run /sync-design-tokens export in Claude Code to project the change to the tool."
3276
+ };
3277
+ }
3278
+ };
3156
3279
  //#endregion
3157
3280
  //#region src/status/status.ts
3158
- /** The state label whose presence on an open issue marks a live discovery (#55c/e). */
3159
3281
  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
3282
  const runStatus = async (deps) => {
3168
3283
  const { repoRoot } = deps;
3169
3284
  const config = await readHarnessConfig(repoRoot);
@@ -3177,10 +3292,21 @@ const runStatus = async (deps) => {
3177
3292
  branch: branch ?? pointer.branch,
3178
3293
  behind,
3179
3294
  lastSession: pointer.lastSession,
3180
- openDiscoveries
3295
+ openDiscoveries,
3296
+ designToolDrift: await readDriftState(repoRoot)
3181
3297
  };
3182
3298
  };
3183
- /** Read `active_task` / `branch` / `last_close_ts` from the per-dev pointer frontmatter. */
3299
+ const readDriftState = async (repoRoot) => {
3300
+ const tokenPath = join(repoRoot, DESIGN_TOKENS_FILE);
3301
+ if (!await pathExists(tokenPath)) return null;
3302
+ try {
3303
+ const parsed = JSON.parse(await readFile(tokenPath, "utf8"));
3304
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
3305
+ return driftReport(parsed).state;
3306
+ } catch {
3307
+ return null;
3308
+ }
3309
+ };
3184
3310
  const readPointer = async (repoRoot, readGitUserEmail) => {
3185
3311
  const empty = {
3186
3312
  activeTask: null,
@@ -3209,13 +3335,11 @@ const readPointer = async (repoRoot, readGitUserEmail) => {
3209
3335
  lastSession: normalize(front.last_close_ts)
3210
3336
  };
3211
3337
  };
3212
- /** YAML `null`, the literal string "null", and "" all read as "unset". */
3213
3338
  const normalize = (value) => {
3214
3339
  if (value === null || value === void 0) return null;
3215
3340
  const trimmed = String(value).trim();
3216
3341
  return trimmed === "" || trimmed === "null" ? null : trimmed;
3217
3342
  };
3218
- /** Count open issues paused on a discovery; null on any `gh` failure (best-effort). */
3219
3343
  const countOpenDiscoveries = async (provider, slug) => {
3220
3344
  const result = await provider.listIssues(slug, {
3221
3345
  state: "open",
@@ -3228,11 +3352,6 @@ const countOpenDiscoveries = async (provider, slug) => {
3228
3352
  return null;
3229
3353
  }
3230
3354
  };
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
3355
  const COMMANDS = [
3237
3356
  {
3238
3357
  name: "install",
@@ -3279,6 +3398,11 @@ const COMMANDS = [
3279
3398
  summary: "Reflect a raised/resolved discovery onto its issue (label flip + comment); used by the Orchestrator's resolve-discovery skill.",
3280
3399
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
3281
3400
  },
3401
+ {
3402
+ name: "design-tokens",
3403
+ 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).",
3404
+ usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
3405
+ },
3282
3406
  {
3283
3407
  name: "spinoff",
3284
3408
  summary: "Capture a non-blocking defect found mid-task as a pending stub (+ followup_captured event); used by the /spinoff command.",
@@ -3293,7 +3417,6 @@ const COMMANDS = [
3293
3417
  //#endregion
3294
3418
  //#region src/help/help.ts
3295
3419
  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
3420
  const topLevelHelp = () => {
3298
3421
  const width = Math.max(...COMMANDS.map((c) => c.name.length));
3299
3422
  return [
@@ -3308,7 +3431,6 @@ const topLevelHelp = () => {
3308
3431
  "`lemony version` (or `--version` / `-v`) prints the installed CLI version."
3309
3432
  ].join("\n");
3310
3433
  };
3311
- /** Per-command help (usage + summary), or `undefined` for an unknown command. */
3312
3434
  const commandHelp = (name) => {
3313
3435
  const command = COMMANDS.find((c) => c.name === name);
3314
3436
  if (!command) return void 0;
@@ -3318,56 +3440,21 @@ const commandHelp = (name) => {
3318
3440
  command.summary
3319
3441
  ].join("\n");
3320
3442
  };
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
3443
  const unknownCommand = (name) => `Unknown command "${name ?? ""}". Run \`lemony --help\` to see available commands.`;
3326
3444
  //#endregion
3327
3445
  //#region src/render/render.ts
3328
3446
  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
3447
  const renderTemplate = (template, vars) => template.replace(PLACEHOLDER, (_match, key) => {
3336
3448
  if (!Object.hasOwn(vars, key)) throw new Error(`renderTemplate: no value provided for "${key}"`);
3337
3449
  return vars[key];
3338
3450
  });
3339
3451
  //#endregion
3340
3452
  //#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
3453
  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
3454
  const STAGING_DIR$1 = ".staging";
3353
- /** True when a filesystem error is "the path does not exist". */
3354
3455
  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
3456
  const baselineRootDir = (repoRoot) => join(repoRoot, BASELINE_ROOT_REL);
3357
- /** The dir holding the pristine vendor tree for one version. */
3358
3457
  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
3458
  const writeBaseline = async (repoRoot, version, files) => {
3372
3459
  const root = baselineRootDir(repoRoot);
3373
3460
  const staging = join(root, STAGING_DIR$1);
@@ -3389,7 +3476,6 @@ const writeBaseline = async (repoRoot, version, files) => {
3389
3476
  await rename(staging, versionDir);
3390
3477
  await pruneOtherVersions(root, version);
3391
3478
  };
3392
- /** Remove every version dir under `root` except `keep` (single-version retention). */
3393
3479
  const pruneOtherVersions = async (root, keep) => {
3394
3480
  const entries = await readdir(root, { withFileTypes: true });
3395
3481
  await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name !== keep).map((entry) => rm(join(root, entry.name), {
@@ -3397,11 +3483,6 @@ const pruneOtherVersions = async (root, keep) => {
3397
3483
  force: true
3398
3484
  })));
3399
3485
  };
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
3486
  const readBaseline = async (repoRoot, version) => {
3406
3487
  const versionDir = baselineVersionDir(repoRoot, version);
3407
3488
  let relPaths;
@@ -3414,25 +3495,6 @@ const readBaseline = async (repoRoot, version) => {
3414
3495
  const entries = await Promise.all(relPaths.map(async (relPath) => [relPath, await readFile(join(versionDir, relPath), "utf8")]));
3415
3496
  return new Map(entries);
3416
3497
  };
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
3498
  const findBaselineVersion = async (repoRoot) => {
3437
3499
  const root = baselineRootDir(repoRoot);
3438
3500
  let entries;
@@ -3453,31 +3515,13 @@ const findBaselineVersion = async (repoRoot) => {
3453
3515
  };
3454
3516
  //#endregion
3455
3517
  //#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
3518
  const SNAPSHOTS_ROOT_REL = join(".claude", ".harness", "snapshots");
3462
- /** Where a bundle is built before promotion — a dot-dir `listSnapshots` skips. */
3463
3519
  const STAGING_DIR = ".staging";
3464
- /** The two file-tree halves of a bundle (ADR 0005 D7). */
3465
3520
  const WORKING_SUBDIR = "working";
3466
3521
  const BASELINE_SUBDIR = "baseline";
3467
- /** True when a filesystem error is "the path does not exist". */
3468
3522
  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
3523
  const snapshotsRootDir = (repoRoot) => join(repoRoot, SNAPSHOTS_ROOT_REL);
3471
- /** The dir holding one version's two-part snapshot bundle. */
3472
3524
  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
3525
  const collectWorkingFiles = async (repoRoot, relPaths) => {
3482
3526
  return (await Promise.all(relPaths.map(async (relPath) => {
3483
3527
  const filePath = join(repoRoot, relPath);
@@ -3490,15 +3534,9 @@ const collectWorkingFiles = async (repoRoot, relPaths) => {
3490
3534
  };
3491
3535
  }))).filter((file) => file !== null);
3492
3536
  };
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
3537
  const writeWorkingTree = async (destRoot, files) => {
3499
3538
  await Promise.all(files.map(({ relPath, content, executable }) => writeManaged(destRoot, relPath, content, executable)));
3500
3539
  };
3501
- /** Write a baseline map under `destRoot` (content only — a merge base is never run). */
3502
3540
  const writeBaselineTree = async (destRoot, baseline) => {
3503
3541
  await Promise.all([...baseline].map(async ([relPath, content]) => {
3504
3542
  const dest = join(destRoot, relPath);
@@ -3506,15 +3544,6 @@ const writeBaselineTree = async (destRoot, baseline) => {
3506
3544
  await writeFile(dest, content);
3507
3545
  }));
3508
3546
  };
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
3547
  const snapshotDestPaths = (version, bundle) => {
3519
3548
  const stagingRel = join(SNAPSHOTS_ROOT_REL, STAGING_DIR);
3520
3549
  return [
@@ -3525,20 +3554,6 @@ const snapshotDestPaths = (version, bundle) => {
3525
3554
  join(SNAPSHOTS_ROOT_REL, version)
3526
3555
  ];
3527
3556
  };
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
3557
  const writeSnapshot = async (repoRoot, version, bundle) => {
3543
3558
  const staging = join(snapshotsRootDir(repoRoot), STAGING_DIR);
3544
3559
  await assertNoSymlinkTraversal(repoRoot, snapshotDestPaths(version, bundle));
@@ -3557,13 +3572,6 @@ const writeSnapshot = async (repoRoot, version, bundle) => {
3557
3572
  });
3558
3573
  await rename(staging, dir);
3559
3574
  };
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
3575
  const listSnapshots = async (repoRoot) => {
3568
3576
  const root = snapshotsRootDir(repoRoot);
3569
3577
  let entries;
@@ -3581,11 +3589,6 @@ const listSnapshots = async (repoRoot) => {
3581
3589
  withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs || b.version.localeCompare(a.version, void 0, { numeric: true }));
3582
3590
  return withMtime.map((entry) => entry.version);
3583
3591
  };
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
3592
  const readSnapshot = async (repoRoot, version) => {
3590
3593
  const dir = snapshotDir(repoRoot, version);
3591
3594
  if (!await pathExists(dir)) return null;
@@ -3608,11 +3611,6 @@ const readSnapshot = async (repoRoot, version) => {
3608
3611
  baseline: new Map(baselineEntries)
3609
3612
  };
3610
3613
  };
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
3614
  const rotateSnapshots = async (repoRoot, keep) => {
3617
3615
  if (keep === "unlimited") return [];
3618
3616
  const toRemove = (await listSnapshots(repoRoot)).slice(keep);
@@ -3623,10 +3621,6 @@ const rotateSnapshots = async (repoRoot, keep) => {
3623
3621
  })));
3624
3622
  return toRemove;
3625
3623
  };
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
3624
  const cleanupSnapshots = async (repoRoot) => {
3631
3625
  const versions = await listSnapshots(repoRoot);
3632
3626
  await assertNoSymlinkTraversal(repoRoot, [SNAPSHOTS_ROOT_REL]);
@@ -3638,49 +3632,15 @@ const cleanupSnapshots = async (repoRoot) => {
3638
3632
  };
3639
3633
  //#endregion
3640
3634
  //#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
3635
  const CONFLICT_MARKER_CLIENT = "<<<<<<< client";
3648
3636
  const CONFLICT_MARKER_SEPARATOR = "=======";
3649
3637
  const CONFLICT_MARKER_VENDOR = ">>>>>>> vendor";
3650
3638
  //#endregion
3651
3639
  //#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
3640
  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
3641
  const hasConflictMarkers = (content) => CONFLICT_FENCE.test(content);
3668
3642
  //#endregion
3669
3643
  //#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
3644
  const threeWayMerge = (base, client, vendor) => {
3685
3645
  const regions = diff3Regions(base.split("\n"), client.split("\n"), vendor.split("\n"));
3686
3646
  const out = [];
@@ -3756,12 +3716,6 @@ const diff3Regions = (base, client, vendor) => {
3756
3716
  emitStable(base.length);
3757
3717
  return regions;
3758
3718
  };
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
3719
  const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) => {
3766
3720
  const own = group.filter((h) => h.side === side);
3767
3721
  let sideStart = Number.POSITIVE_INFINITY;
@@ -3778,11 +3732,6 @@ const projectSide = (group, side, sideLines, regionBaseStart, regionBaseEnd) =>
3778
3732
  const end = sideEnd + (regionBaseEnd - baseEnd);
3779
3733
  return sideLines.slice(start, end);
3780
3734
  };
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
3735
  const diffRegions = (base, other) => {
3787
3736
  const matches = lcsMatches(base, other);
3788
3737
  const diffs = [];
@@ -3806,12 +3755,6 @@ const diffRegions = (base, other) => {
3806
3755
  });
3807
3756
  return diffs;
3808
3757
  };
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
3758
  const lcsMatches = (base, other) => {
3816
3759
  const n = base.length;
3817
3760
  const m = other.length;
@@ -3832,45 +3775,13 @@ const lcsMatches = (base, other) => {
3832
3775
  };
3833
3776
  //#endregion
3834
3777
  //#region src/update/engine.ts
3835
- /** The managed skills root — the only multi-file unit the cohesion grouping spans. */
3836
3778
  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
3779
  const cohesionKey = (relPath) => {
3846
3780
  const prefix = SKILLS_PREFIX + sep;
3847
3781
  if (!relPath.startsWith(prefix)) return relPath;
3848
3782
  const name = relPath.slice(prefix.length).split(sep)[0];
3849
3783
  return name ? join(SKILLS_PREFIX, name) : relPath;
3850
3784
  };
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
3785
  const runEngine = async (inputs) => {
3875
3786
  const { baseline, readClient, onConflict } = inputs;
3876
3787
  const vendorNew = new Map(inputs.vendorNew.map((file) => [file.relPath, file]));
@@ -3901,7 +3812,6 @@ const runEngine = async (inputs) => {
3901
3812
  return action;
3902
3813
  });
3903
3814
  };
3904
- /** Decide a path the new catalog still ships (`vendor` defined). */
3905
3815
  const decideManaged = async (relPath, base, vendor, client, onConflict) => {
3906
3816
  const { content, executable } = vendor;
3907
3817
  if (base === void 0) {
@@ -3944,7 +3854,6 @@ const decideManaged = async (relPath, base, vendor, client, onConflict) => {
3944
3854
  conflicted: merge.conflicted
3945
3855
  };
3946
3856
  };
3947
- /** The action for one orphan, given its group's adopt-vs-prune decision. */
3948
3857
  const orphanAction = (relPath, client, adopts) => {
3949
3858
  if (client === null) return {
3950
3859
  relPath,
@@ -3958,7 +3867,6 @@ const orphanAction = (relPath, client, adopts) => {
3958
3867
  kind: "prune"
3959
3868
  };
3960
3869
  };
3961
- /** Group items by a key, preserving insertion order within each group. */
3962
3870
  const groupBy = (items, key) => {
3963
3871
  const groups = /* @__PURE__ */ new Map();
3964
3872
  for (const item of items) {
@@ -3971,35 +3879,10 @@ const groupBy = (items, key) => {
3971
3879
  };
3972
3880
  //#endregion
3973
3881
  //#region src/install/asset-frontmatter.ts
3974
- /** Leading `---\n … \n---` block, with the fences captured so we can rebuild it. */
3975
3882
  const FRONTMATTER_BLOCK = /^(---\n)([\s\S]*?)(\n---)/;
3976
- /** `{{ vendor_version }}` placeholder (whitespace-tolerant, like `renderTemplate`). */
3977
3883
  const VENDOR_VERSION = /\{\{\s*vendor_version\s*\}\}/g;
3978
- /** The first `description:` line within a frontmatter block. */
3979
3884
  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
3885
  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
3886
  const materializeAssetFrontmatter = (content, opts) => {
4004
3887
  const prefix = opts.descriptionPrefix ?? "🍋";
4005
3888
  const match = FRONTMATTER_BLOCK.exec(content);
@@ -4013,21 +3896,14 @@ const materializeAssetFrontmatter = (content, opts) => {
4013
3896
  };
4014
3897
  //#endregion
4015
3898
  //#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
3899
  const CORE_AGENTS = [
4024
3900
  "orchestrator",
4025
3901
  "spec-author",
4026
3902
  "implementer",
4027
3903
  "reviewer",
4028
- "architect"
3904
+ "architect",
3905
+ "ui-designer"
4029
3906
  ];
4030
- /** Read a template and render its `{{vars}}` to a `VendorFile` at `relPath`. */
4031
3907
  const renderFile = async (templatePath, relPath, vars) => {
4032
3908
  return {
4033
3909
  relPath,
@@ -4035,34 +3911,15 @@ const renderFile = async (templatePath, relPath, vars) => {
4035
3911
  executable: false
4036
3912
  };
4037
3913
  };
4038
- /** Read a vendor file verbatim into a `VendorFile` at `relPath`. */
4039
3914
  const copyFileEntry = async (srcPath, relPath, executable = false) => ({
4040
3915
  relPath,
4041
3916
  content: await readFile(srcPath, "utf8"),
4042
3917
  executable
4043
3918
  });
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
3919
  const withAssetFrontmatter = (file, vendorVersion) => ({
4050
3920
  ...file,
4051
3921
  content: materializeAssetFrontmatter(file.content, { vendorVersion })
4052
3922
  });
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
3923
  const materializeVendorFiles = async (ctx) => {
4067
3924
  const { vendorRoot, target, vars, skills } = ctx;
4068
3925
  const templateRoot = join(vendorRoot, "templates", target);
@@ -4100,16 +3957,6 @@ const materializeVendorFiles = async (ctx) => {
4100
3957
  };
4101
3958
  //#endregion
4102
3959
  //#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
3960
  const GITIGNORE_BEGIN = "# BEGIN lemony";
4114
3961
  const GITIGNORE_END = "# END lemony";
4115
3962
  const GITIGNORE_BLOCK = [
@@ -4125,14 +3972,6 @@ const GITIGNORE_BLOCK = [
4125
3972
  ".claude/.harness/snapshots/",
4126
3973
  GITIGNORE_END
4127
3974
  ].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
3975
  const appendGitignoreBlock = async (repoRoot) => {
4137
3976
  const gitignorePath = join(repoRoot, ".gitignore");
4138
3977
  const current = await pathExists(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
@@ -4147,19 +3986,6 @@ const appendGitignoreBlock = async (repoRoot) => {
4147
3986
  await writeFile(gitignorePath, `${current}${current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n"}${GITIGNORE_BLOCK}\n`);
4148
3987
  return ".gitignore";
4149
3988
  };
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
3989
  const removeGitignoreBlock = async (repoRoot) => {
4164
3990
  const relPath = ".gitignore";
4165
3991
  const gitignorePath = join(repoRoot, relPath);
@@ -4178,17 +4004,6 @@ const removeGitignoreBlock = async (repoRoot) => {
4178
4004
  };
4179
4005
  //#endregion
4180
4006
  //#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
4007
  const mergeSettingsHooks = async (templatePath, destPath) => {
4193
4008
  const templateRaw = await readFile(templatePath, "utf8");
4194
4009
  const template = JSON.parse(templateRaw);
@@ -4211,15 +4026,6 @@ const mergeSettingsHooks = async (templatePath, destPath) => {
4211
4026
  await writeFile(destPath, `${JSON.stringify(merged, null, 2)}\n`);
4212
4027
  return { lostCommands };
4213
4028
  };
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
4029
  const removeSettingsHooks = async (settingsPath) => {
4224
4030
  if (!await pathExists(settingsPath)) return "absent";
4225
4031
  const raw = await readFile(settingsPath, "utf8");
@@ -4239,13 +4045,6 @@ const removeSettingsHooks = async (settingsPath) => {
4239
4045
  await writeFile(settingsPath, `${JSON.stringify(rest, null, 2)}\n`);
4240
4046
  return "stripped";
4241
4047
  };
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
4048
  const collectCommands = (hooks) => {
4250
4049
  const result = /* @__PURE__ */ new Set();
4251
4050
  if (!hooks || typeof hooks !== "object") return result;
@@ -4262,11 +4061,6 @@ const collectCommands = (hooks) => {
4262
4061
  }
4263
4062
  return result;
4264
4063
  };
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
4064
  const diffLostCommands = (existingHooks, templateHooks) => {
4271
4065
  const existing = collectCommands(existingHooks);
4272
4066
  const template = collectCommands(templateHooks);
@@ -4276,18 +4070,6 @@ const diffLostCommands = (existingHooks, templateHooks) => {
4276
4070
  };
4277
4071
  //#endregion
4278
4072
  //#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
4073
  const resyncSpecialCased = async (repoRoot, templateRoot) => {
4292
4074
  const { lostCommands } = await mergeSettingsHooks(join(templateRoot, ".claude", "settings.json.tpl"), join(repoRoot, ".claude", "settings.json"));
4293
4075
  return {
@@ -4298,35 +4080,19 @@ const resyncSpecialCased = async (repoRoot, templateRoot) => {
4298
4080
  //#endregion
4299
4081
  //#region src/install/install.ts
4300
4082
  const CLAUDE_MD_FILENAME = "CLAUDE.md";
4301
- /** Non-interactive default: vendor wins every collision (the non-TTY contract, D8). */
4302
4083
  const VENDOR_WINS = async () => "vendor";
4303
4084
  const OPERATIVE_TARGET = "claude-code";
4304
- /** Shared, committed state subdirs — scaffolded empty, kept in git via `.gitkeep`. */
4305
4085
  const COMMITTED_STATE_DIRS = ["tasks", "metrics"];
4306
- /** Write a `VendorFile` to disk via the shared managed-write (parent dirs + exec mode). */
4307
4086
  const writeVendorFile = async (repoRoot, file) => {
4308
4087
  await writeManaged(repoRoot, file.relPath, file.content, file.executable);
4309
4088
  return file.relPath;
4310
4089
  };
4311
- /** Write `content` to a repo-relative path, creating parent dirs. Returns the path. */
4312
4090
  const writeOut = async (repoRoot, relPath, content) => {
4313
4091
  const dest = join(repoRoot, relPath);
4314
4092
  await mkdir(dirname(dest), { recursive: true });
4315
4093
  await writeFile(dest, content);
4316
4094
  return relPath;
4317
4095
  };
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
4096
  const runInstall = async (options) => {
4331
4097
  const { repoRoot, vendorRoot } = options;
4332
4098
  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 +4203,12 @@ const runInstall = async (options) => {
4437
4203
  latentCapabilities: latent
4438
4204
  };
4439
4205
  };
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
4206
  const collectLosers = (repoRoot, actions) => {
4448
4207
  return collectWorkingFiles(repoRoot, actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath));
4449
4208
  };
4450
4209
  //#endregion
4451
4210
  //#region src/install/resolve-collision.ts
4452
4211
  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
4212
  const renderDiff = (relPath, vendorContent, clientContent) => {
4460
4213
  return [
4461
4214
  `--- ${relPath} (your current copy)`,
@@ -4464,14 +4217,6 @@ const renderDiff = (relPath, vendorContent, clientContent) => {
4464
4217
  ...toLines(vendorContent).map((line) => `+ ${line}`)
4465
4218
  ].join("\n");
4466
4219
  };
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
4220
  const createInteractiveCollisionResolver = (deps) => {
4476
4221
  return async (relPath, vendorContent, clientContent) => {
4477
4222
  deps.print("");
@@ -4491,33 +4236,6 @@ const createInteractiveCollisionResolver = (deps) => {
4491
4236
  };
4492
4237
  //#endregion
4493
4238
  //#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
4239
  const runReconcile = async (inputs) => {
4522
4240
  const { repoRoot, vendorRoot, toVersion, configBumps } = inputs;
4523
4241
  const dryRun = inputs.dryRun ?? false;
@@ -4606,7 +4324,6 @@ const applyActions = async (repoRoot, actions) => {
4606
4324
  }));
4607
4325
  await pruneEmptyDirs(repoRoot, actions.filter((action) => action.kind === "prune").map((action) => action.relPath));
4608
4326
  };
4609
- /** Bucket the engine actions into the report's path lists. */
4610
4327
  const summarize = (actions) => {
4611
4328
  const conflictedFiles = [];
4612
4329
  const mergedFiles = [];
@@ -4653,13 +4370,6 @@ const summarize = (actions) => {
4653
4370
  };
4654
4371
  //#endregion
4655
4372
  //#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
4373
  const runUpdate = async (options) => {
4664
4374
  const { repoRoot, vendorRoot } = options;
4665
4375
  const onConflict = options.onConflict ?? "vendor";
@@ -4683,16 +4393,6 @@ const runUpdate = async (options) => {
4683
4393
  };
4684
4394
  //#endregion
4685
4395
  //#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
4396
  const runRepair = async (options) => {
4697
4397
  const { repoRoot, vendorRoot } = options;
4698
4398
  const config = await readHarnessConfig(repoRoot);
@@ -4707,35 +4407,9 @@ const runRepair = async (options) => {
4707
4407
  };
4708
4408
  //#endregion
4709
4409
  //#region src/uninstall/uninstall.ts
4710
- /** The harness-private tree (baseline + snapshots) — removed wholesale. */
4711
4410
  const HARNESS_DIR = join(".claude", ".harness");
4712
- /** Vendor authority over settings is the `hooks` block only (preserve client keys). */
4713
4411
  const SETTINGS_RELPATH = join(".claude", "settings.json");
4714
- /** All of `docs/` is preserved (D7) — even the vendor `playbooks/README.md`. */
4715
4412
  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
4413
  const runUninstall = async (options) => {
4740
4414
  const { repoRoot } = options;
4741
4415
  if (!await pathExists(join(repoRoot, "harness.config.yml"))) throw new Error(`Lemony is not installed here (${HARNESS_CONFIG_FILENAME} not found).`);
@@ -4770,18 +4444,6 @@ const runUninstall = async (options) => {
4770
4444
  };
4771
4445
  //#endregion
4772
4446
  //#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
4447
  const runRollback = async (options) => {
4786
4448
  const { repoRoot, force } = options;
4787
4449
  const snapshots = await listSnapshots(repoRoot);
@@ -4817,15 +4479,6 @@ const runRollback = async (options) => {
4817
4479
  removedFiles: removePaths
4818
4480
  };
4819
4481
  };
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
4482
  const dirtyManagedPaths = async (repoRoot, run, touched) => {
4830
4483
  const result = await run("git", [
4831
4484
  "-C",
@@ -4854,30 +4507,12 @@ const dirtyManagedPaths = async (repoRoot, run, touched) => {
4854
4507
  //#endregion
4855
4508
  //#region src/install/resolve-slug.ts
4856
4509
  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
4510
  const validateSlugOrThrow = (slug) => {
4864
4511
  const trimmed = slug.trim();
4865
4512
  if (trimmed === "") throw new Error("task_storage.repo cannot be empty.");
4866
4513
  if (trimmed === "OWNER/REPO") throw new Error(`task_storage.repo cannot be the OWNER/REPO placeholder — set a real slug.`);
4867
4514
  if (!SLUG_PATTERN.test(trimmed)) throw new Error(`task_storage.repo must be in owner/name format (got "${slug}").`);
4868
4515
  };
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
4516
  const resolveTaskStorageRepo = async (input) => {
4882
4517
  const { scannedSlug, flagSlug, deps } = input;
4883
4518
  if (flagSlug) return {
@@ -4935,13 +4570,6 @@ const promptManualSlug = async (deps) => {
4935
4570
  }
4936
4571
  }
4937
4572
  };
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
4573
  const tryGhCreate = async (deps) => {
4946
4574
  const slug = await promptValidSlug(deps, "Full slug (owner/name): ");
4947
4575
  const visibility = await promptVisibility(deps);
@@ -5015,11 +4643,6 @@ const readHarnessVersion = async () => {
5015
4643
  if (!parsed.version) throw new Error(`package.json at ${PACKAGE_JSON_PATH} has no version.`);
5016
4644
  return parsed.version;
5017
4645
  };
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
4646
  const makeRunCommand = () => async (cmd, args) => {
5024
4647
  try {
5025
4648
  const result = await execFileAsync(cmd, args);
@@ -5042,13 +4665,6 @@ const makeRunCommand = () => async (cmd, args) => {
5042
4665
  };
5043
4666
  }
5044
4667
  };
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
4668
  const buildResolveDeps = () => {
5053
4669
  const rl = createInterface({
5054
4670
  input: stdin,
@@ -5066,11 +4682,6 @@ const buildResolveDeps = () => {
5066
4682
  close: () => rl.close()
5067
4683
  };
5068
4684
  };
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
4685
  const gitBehind = async (repoRoot) => {
5075
4686
  const run = makeRunCommand();
5076
4687
  const branchResult = await run("git", [
@@ -5164,35 +4775,16 @@ const install = async (args) => {
5164
4775
  });
5165
4776
  } catch {}
5166
4777
  };
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
4778
  const reportLatentCapabilities = (latent) => {
5174
4779
  if (latent.length === 0) return;
5175
4780
  console.log("Opt-in capabilities available (not installed):");
5176
4781
  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
- */
4782
+ console.log(" Run /add-capability in Claude Code to activate one — the Architect authors the artifact for you (opt-in, never imposed).");
4783
+ };
5185
4784
  const reportDevDependency = (devDependencyMissing) => {
5186
4785
  if (!devDependencyMissing) return;
5187
4786
  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
4787
  };
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
4788
  const reportCollisions = (collisions, snapshotVersion, mode) => {
5197
4789
  if (collisions.length === 0) return;
5198
4790
  const vendorWins = collisions.filter((c) => c.winner === "vendor");
@@ -5204,20 +4796,10 @@ const reportCollisions = (collisions, snapshotVersion, mode) => {
5204
4796
  for (const { relPath } of vendorWins) console.log(` used vendor copy: ${relPath}`);
5205
4797
  if (snapshotVersion !== null) console.log(`Your displaced ${vendorWins.length === 1 ? "copy was" : "copies were"} saved — restore with \`lemony rollback --to ${snapshotVersion}\`.`);
5206
4798
  };
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
4799
  const reportClaudeMd = (claudeMdPresent) => {
5213
4800
  if (!claudeMdPresent) return;
5214
4801
  console.log("Found an existing CLAUDE.md — left untouched. Review it for guidance that now overlaps the installed harness (agents.md, .claude/).");
5215
4802
  };
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
4803
  const reportLostHooks = (lostHookCommands) => {
5222
4804
  if (lostHookCommands.length === 0) return;
5223
4805
  const count = lostHookCommands.length;
@@ -5225,14 +4807,6 @@ const reportLostHooks = (lostHookCommands) => {
5225
4807
  for (const command of lostHookCommands) console.log(` ${command}`);
5226
4808
  console.log("Restore them in .claude/settings.local.json if you want them back.");
5227
4809
  };
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
4810
  const update = async (args) => {
5237
4811
  const repoRoot = cwd();
5238
4812
  const onConflict = parseOnConflict(args);
@@ -5259,11 +4833,6 @@ const update = async (args) => {
5259
4833
  }
5260
4834
  };
5261
4835
  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
4836
  const parseOnConflict = (args) => {
5268
4837
  const raw = parseFlag(args, "on-conflict");
5269
4838
  if (raw === void 0) return void 0;
@@ -5273,12 +4842,6 @@ const parseOnConflict = (args) => {
5273
4842
  }
5274
4843
  return raw;
5275
4844
  };
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
4845
  const repair = async (args) => {
5283
4846
  const dryRun = args.includes("--dry-run");
5284
4847
  const result = await runRepair({
@@ -5297,12 +4860,6 @@ const repair = async (args) => {
5297
4860
  reportLostHooks(result.lostHookCommands);
5298
4861
  }
5299
4862
  };
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
4863
  const uninstall = async (args) => {
5307
4864
  const requestedLabels = args.includes("--labels");
5308
4865
  const result = await runUninstall({
@@ -5321,22 +4878,9 @@ const uninstall = async (args) => {
5321
4878
  console.log(" Preserved: CLAUDE.md, CONTEXT.md, docs/, .claude/state/, events.jsonl, adopted skills.");
5322
4879
  reportLabelDeletion(result.labelDeletion, requestedLabels);
5323
4880
  };
5324
- /** Print the label outcome for `uninstall` — formatting lives in `formatLabelDeletion`. */
5325
4881
  const reportLabelDeletion = (deletion, requested) => {
5326
4882
  for (const line of formatLabelDeletion(deletion, requested)) console.log(line);
5327
4883
  };
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
4884
  const rollback = async (args) => {
5341
4885
  const repoRoot = cwd();
5342
4886
  const requestedModes = [
@@ -5377,30 +4921,18 @@ const rollback = async (args) => {
5377
4921
  console.log(" newest one. To undo an `update`, see `rollback --list` then `rollback --to=<version>`.");
5378
4922
  }
5379
4923
  };
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
4924
  const reportConflicts = (conflictedFiles, trailer) => {
5386
4925
  if (conflictedFiles.length === 0) return;
5387
4926
  console.log(`\n${conflictedFiles.length} file(s) need conflict resolution:`);
5388
4927
  for (const relPath of conflictedFiles) console.log(` ${relPath}`);
5389
4928
  if (trailer) console.log(trailer);
5390
4929
  };
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
4930
  const reportAdoptions = (adoptedFiles, label = "adopted") => {
5397
4931
  for (const relPath of adoptedFiles) console.log(` ${label} ${relPath} (kept your copy; no longer vendor-managed).`);
5398
4932
  };
5399
- /** Print the harness:* label reconciliation outcome — formatting in `formatLabelSync`. */
5400
4933
  const reportLabelSync = (sync) => {
5401
4934
  for (const line of formatLabelSync(sync)) console.log(line);
5402
4935
  };
5403
- /** Print a verb's best-effort label preflight — formatting in `formatLabelSelfHeal`. */
5404
4936
  const reportLabelSelfHeal = (sync) => {
5405
4937
  for (const line of formatLabelSelfHeal(sync)) console.log(line);
5406
4938
  };
@@ -5413,14 +4945,6 @@ const emit = async (args) => {
5413
4945
  });
5414
4946
  console.log(`emitted ${event.type} → ${eventsPath}`);
5415
4947
  };
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
4948
  const runTelemetry = async (args) => {
5425
4949
  const action = args[0] ?? "status";
5426
4950
  const repoRoot = cwd();
@@ -5452,7 +4976,6 @@ const runTelemetry = async (args) => {
5452
4976
  default: throw new Error(`Usage: lemony telemetry <status|show|flush|disable|enable>. Unknown action "${action}".`);
5453
4977
  }
5454
4978
  };
5455
- /** `telemetry status` — print the resolved on/off, decision source, and watermark. */
5456
4979
  const telemetryStatus = async (repoRoot) => {
5457
4980
  const report = await buildTelemetryStatus({
5458
4981
  repoRoot,
@@ -5460,16 +4983,10 @@ const telemetryStatus = async (repoRoot) => {
5460
4983
  });
5461
4984
  for (const line of formatTelemetryStatus(report)) console.log(line);
5462
4985
  };
5463
- /** `telemetry show` — dump every local event raw, then its sanitized projection. */
5464
4986
  const telemetryShow = async (repoRoot) => {
5465
4987
  const report = await buildTelemetryShow(repoRoot);
5466
4988
  for (const line of formatTelemetryShow(report)) console.log(line);
5467
4989
  };
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
4990
  const telemetryDisable = async (repoRoot, args) => {
5474
4991
  await disableLocally(repoRoot);
5475
4992
  const purge = args.includes("--purge-local");
@@ -5481,11 +4998,6 @@ const telemetryDisable = async (repoRoot, args) => {
5481
4998
  env
5482
4999
  }))) console.log(line);
5483
5000
  };
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
5001
  const telemetryEnable = async (repoRoot) => {
5490
5002
  await enableLocally(repoRoot);
5491
5003
  const report = await buildTelemetryStatus({
@@ -5495,11 +5007,6 @@ const telemetryEnable = async (repoRoot) => {
5495
5007
  console.log(report.enabled ? "Telemetry enabled (anonymous) for this checkout." : "Local opt-out cleared, but telemetry is still OFF for another reason:");
5496
5008
  for (const line of formatTelemetryStatus(report)) console.log(line);
5497
5009
  };
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
5010
  const telemetrySend = async (repoRoot) => {
5504
5011
  const result = await sendTelemetry({
5505
5012
  repoRoot,
@@ -5507,11 +5014,6 @@ const telemetrySend = async (repoRoot) => {
5507
5014
  });
5508
5015
  console.log(`telemetry ${result.outcome} (${result.segmentsSent} sent, ${result.quarantined} quarantined)`);
5509
5016
  };
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
5017
  const telemetryFlush = async (repoRoot) => {
5516
5018
  const result = await sendTelemetry({
5517
5019
  repoRoot,
@@ -5539,14 +5041,113 @@ const discovery = async (args) => {
5539
5041
  if (result.applied.length > 0) console.error(` Applied before failure: ${result.applied.join(", ")}.`);
5540
5042
  exit(1);
5541
5043
  };
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
- */
5044
+ const designTokens = async (args) => {
5045
+ const action = args[0] ?? "validate";
5046
+ if (action === "validate") return designTokensValidate(args);
5047
+ if (action === "contrast") return designTokensContrast();
5048
+ if (action === "import") return designTokensImport(args);
5049
+ if (action === "export") return designTokensExport(args);
5050
+ 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}".`);
5051
+ };
5052
+ const designTokensValidate = async (args) => {
5053
+ const config = await readHarnessConfig(cwd()).catch(() => void 0);
5054
+ const result = await runValidate({
5055
+ repoRoot: cwd(),
5056
+ scanDir: parseFlag(args, "scan"),
5057
+ extraExtensions: config?.design_tokens.scan_extensions
5058
+ });
5059
+ if (!result.tokensFound) {
5060
+ console.log(`No docs/design-tokens.json — design-tokens validate skipped (consume-if-exists; the harness never creates it).`);
5061
+ return;
5062
+ }
5063
+ if (result.ok) {
5064
+ console.log(`design-tokens valid: ${result.tokenCount} token(s), ${result.filesScanned} file(s) scanned, no hardcoded values.`);
5065
+ return;
5066
+ }
5067
+ console.error(`design-tokens validate found ${result.violations.length} violation(s):`);
5068
+ for (const violation of result.violations) {
5069
+ const where = violation.file ? violation.line !== void 0 ? `${violation.file}:${violation.line} ` : `${violation.file} ` : "";
5070
+ console.error(` ${where}[${violation.kind}] ${violation.message}`);
5071
+ }
5072
+ exit(1);
5073
+ };
5074
+ const designTokensContrast = async () => {
5075
+ const result = await runContrast({ repoRoot: cwd() });
5076
+ if (!result.tokensFound) {
5077
+ console.log(`No docs/design-tokens.json — design-tokens contrast skipped (consume-if-exists; the harness never creates it).`);
5078
+ return;
5079
+ }
5080
+ const failures = result.pairs.filter((pair) => !pair.passes);
5081
+ if (result.ok) {
5082
+ 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.`);
5083
+ return;
5084
+ }
5085
+ console.error(`design-tokens contrast found ${failures.length + result.problems.length} issue(s):`);
5086
+ for (const pair of failures) console.error(` ${pair.foreground} on ${pair.background} (${pair.mode}, ${pair.level}): ${pair.ratio}:1 < ${pair.floor}:1`);
5087
+ for (const problem of result.problems) console.error(` ${problem}`);
5088
+ exit(1);
5089
+ };
5090
+ const designTokensImport = async (args) => {
5091
+ const from = parseFlag(args, "from");
5092
+ if (from === void 0) throw new Error("Usage: lemony design-tokens import --from=<neutral-file> [--apply] [--only=<dotted,paths>]");
5093
+ const only = parseFlag(args, "only")?.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
5094
+ const result = await runImport({
5095
+ repoRoot: cwd(),
5096
+ neutralPath: resolve(cwd(), from),
5097
+ apply: args.includes("--apply"),
5098
+ only
5099
+ });
5100
+ if (result.problems.length > 0) {
5101
+ console.error("design-tokens import could not run:");
5102
+ for (const problem of result.problems) console.error(` ${problem}`);
5103
+ exit(1);
5104
+ }
5105
+ if (result.applied) {
5106
+ console.log(`design-tokens import applied: wrote ${result.written.length} token(s) to ${DESIGN_TOKENS_FILE}.`);
5107
+ 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(", ")}`);
5108
+ return;
5109
+ }
5110
+ const landing = result.changes.filter((change) => change.kind !== "unchanged");
5111
+ const news = landing.filter((change) => change.kind === "new").length;
5112
+ const changed = landing.length - news;
5113
+ console.log(`design-tokens import preview: ${news} new, ${changed} changed, ${result.changes.length - landing.length} unchanged.`);
5114
+ for (const change of landing) {
5115
+ const detail = change.kind === "changed" ? `${change.before} → ${change.after}` : change.after;
5116
+ console.log(` [${change.kind}] ${change.path} (${change.tier}): ${detail}`);
5117
+ }
5118
+ if (landing.length > 0) console.log(`Curate the slice, then: lemony design-tokens import --from=${from} --apply --only=<paths>`);
5119
+ };
5120
+ const designTokensExport = async (args) => {
5121
+ const toolState = parseFlag(args, "tool-state");
5122
+ const out = parseFlag(args, "out");
5123
+ if (out !== void 0 && args.includes("--record")) {
5124
+ console.error("design-tokens export: --out and --record are mutually exclusive — preview/push with --out, then --record only after the push succeeds.");
5125
+ exit(1);
5126
+ }
5127
+ const result = await runExport({
5128
+ repoRoot: cwd(),
5129
+ ...toolState ? { toolStatePath: resolve(cwd(), toolState) } : {},
5130
+ ...out ? { outPath: resolve(cwd(), out) } : {},
5131
+ record: args.includes("--record")
5132
+ });
5133
+ if (!result.tokensFound) {
5134
+ console.log(`No ${DESIGN_TOKENS_FILE} — design-tokens export skipped (consume-if-exists; the harness never creates it).`);
5135
+ return;
5136
+ }
5137
+ if (result.problems.length > 0) {
5138
+ console.error("design-tokens export could not run:");
5139
+ for (const problem of result.problems) console.error(` ${problem}`);
5140
+ exit(1);
5141
+ }
5142
+ if (result.recorded) {
5143
+ console.log(`design-tokens export recorded: drift baseline stamped (${result.hash.slice(0, 12)}).`);
5144
+ return;
5145
+ }
5146
+ const creates = result.plan.filter((entry) => entry.kind === "create").length;
5147
+ const updates = result.plan.filter((entry) => entry.kind === "update").length;
5148
+ console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
5149
+ if (out !== void 0) console.log(` projection written to ${out}`);
5150
+ };
5550
5151
  const spinoff = async (args) => {
5551
5152
  const harnessVersion = await readHarnessVersion();
5552
5153
  const result = await runSpinoff({
@@ -5587,6 +5188,13 @@ const status = async () => {
5587
5188
  console.log(` Branch: ${report.branch ?? "(unknown)"} (${behindText})`);
5588
5189
  console.log(` Last session: ${report.lastSession ?? "(none)"}`);
5589
5190
  console.log(` Open discoveries: ${discoveries}`);
5191
+ if (report.designToolDrift !== null) console.log(` Design tool: ${DESIGN_TOOL_STATUS[report.designToolDrift]}`);
5192
+ };
5193
+ const DESIGN_TOOL_STATUS = {
5194
+ "n/a": "not in use",
5195
+ "never-projected": "declared, never exported (run /sync-design-tokens export)",
5196
+ "in-sync": "in sync",
5197
+ "export-pending": "export pending (run /sync-design-tokens export)"
5590
5198
  };
5591
5199
  const doctor = async () => {
5592
5200
  const report = await runDoctor({
@@ -5666,6 +5274,9 @@ const main = async () => {
5666
5274
  case "discovery":
5667
5275
  await discovery(args);
5668
5276
  return;
5277
+ case "design-tokens":
5278
+ await designTokens(args);
5279
+ return;
5669
5280
  case "spinoff":
5670
5281
  await spinoff(args);
5671
5282
  return;