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