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