@lemoncode/lemony 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,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, extname, join, relative, resolve, sep } from "node:path";
4
+ import { basename, 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";
@@ -23,11 +23,22 @@ const DEPRECATED_PATHS_KEYS = [
23
23
  "skills",
24
24
  "agents"
25
25
  ];
26
+ const DEPRECATED_IMPLEMENTATION_KEYS = ["pre_commit_review"];
26
27
  const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
28
+ const CONFIG_KEY_SINCE = {
29
+ gates: "0.3.0",
30
+ "implementation.auto_commit": "0.4.0"
31
+ };
27
32
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
28
33
  const TARGETS = ["claude-code"];
29
34
  const TASK_STORAGE_TYPES = ["github"];
30
35
  const TASK_STORAGE_REPO_PATTERN = /^[^\s/]+\/[^\s/]+$/;
36
+ const AUTO_COMMIT_MODES = [
37
+ "human",
38
+ "on",
39
+ "off"
40
+ ];
41
+ const AUTO_COMMIT_DEFAULT = "human";
31
42
  const VENDOR_VERSION_REGEX = /^\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$/;
32
43
  const VENDOR_VERSION_EXAMPLE = "0.1.0-alpha.0";
33
44
  const PATHS_DEFAULTS = {
@@ -110,6 +121,12 @@ const taskStorageSchema = z.object({
110
121
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
111
122
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
112
123
  const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
124
+ const mergeSchema = z.object({
125
+ checks_timeout_secs: z.int().positive().default(600),
126
+ allow_no_checks: z.boolean().default(false)
127
+ }).strict().prefault({});
128
+ const implementationSchema = z.object({ auto_commit: z.enum(AUTO_COMMIT_MODES).default(AUTO_COMMIT_DEFAULT) }).strict().prefault({});
129
+ const gatesSchema = z.array(z.string().trim().min(1)).optional();
113
130
  const harnessConfigSchema = z.object({
114
131
  vendor_version: z.string().regex(VENDOR_VERSION_REGEX),
115
132
  target: z.enum(TARGETS),
@@ -117,7 +134,10 @@ const harnessConfigSchema = z.object({
117
134
  paths: pathsSchema,
118
135
  rollback: rollbackSchema,
119
136
  telemetry: telemetrySchema,
120
- design_tokens: designTokensSchema
137
+ design_tokens: designTokensSchema,
138
+ merge: mergeSchema,
139
+ implementation: implementationSchema,
140
+ gates: gatesSchema
121
141
  }).strict();
122
142
  //#endregion
123
143
  //#region src/config/config.ts
@@ -128,7 +148,7 @@ const readHarnessConfig = async (repoRoot) => {
128
148
  raw = await readFile(configPath, "utf8");
129
149
  } catch (cause) {
130
150
  if (cause.code === "ENOENT") throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
131
- throw cause;
151
+ throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
132
152
  }
133
153
  let parsed;
134
154
  try {
@@ -145,6 +165,12 @@ const readHarnessConfig = async (repoRoot) => {
145
165
  for (const key of DEPRECATED_PATHS_KEYS) delete pathsCandidate[key];
146
166
  candidate.paths = pathsCandidate;
147
167
  }
168
+ const implementation = candidate.implementation;
169
+ if (implementation && typeof implementation === "object" && !Array.isArray(implementation)) {
170
+ const implementationCandidate = { ...implementation };
171
+ for (const key of DEPRECATED_IMPLEMENTATION_KEYS) delete implementationCandidate[key];
172
+ candidate.implementation = implementationCandidate;
173
+ }
148
174
  const result = harnessConfigSchema.safeParse(candidate);
149
175
  if (!result.success) throw new Error(`${HARNESS_CONFIG_FILENAME} is invalid:\n${formatConfigError(harnessConfigSchema, result.error)}`);
150
176
  return result.data;
@@ -198,11 +224,15 @@ const compareVendorVersion = (a, b) => {
198
224
  //#region src/config/write-config.ts
199
225
  const setConfigValues = (rawYaml, updates) => {
200
226
  const doc = parseDocument(rawYaml);
201
- for (const [key, value] of Object.entries(updates)) if (doc.has(key)) doc.set(key, value);
227
+ for (const [key, value] of Object.entries(updates)) {
228
+ if (!doc.has(key)) throw new Error(`Cannot write config key "${key}": it does not exist in harness.config.yml. The writer never adds structure (value-bumps only) — add the key by hand first; the template's commented docs show the shape.`);
229
+ doc.set(key, value);
230
+ }
202
231
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
203
232
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
204
233
  const pathsNode = doc.get("paths", true);
205
234
  if (isMap(pathsNode) && pathsNode.items.length === 0) doc.delete("paths");
235
+ for (const key of DEPRECATED_IMPLEMENTATION_KEYS) if (doc.hasIn(["implementation", key])) doc.deleteIn(["implementation", key]);
206
236
  return doc.toString();
207
237
  };
208
238
  const writeConfigValues = async (repoRoot, updates) => {
@@ -211,6 +241,16 @@ const writeConfigValues = async (repoRoot, updates) => {
211
241
  await writeFile(configPath, setConfigValues(raw, updates));
212
242
  };
213
243
  //#endregion
244
+ //#region src/config/find-predated-keys.ts
245
+ const findPredatedKeys = (rawYaml, fromVersion, toVersion, sinceMap) => {
246
+ const doc = parseDocument(rawYaml);
247
+ return Object.entries(sinceMap).filter(([, since]) => compareVendorVersion(since, fromVersion) === 1 && compareVendorVersion(since, toVersion) !== 1 && compareVendorVersion(since, toVersion) !== null).filter(([path]) => !doc.hasIn(path.split("."))).map(([path, since]) => ({
248
+ path,
249
+ since
250
+ })).toSorted((a, b) => a.path.localeCompare(b.path));
251
+ };
252
+ const formatPredatedKeyLine = ({ path, since }) => ` new config key \`${path}\` (added in ${since}) — your config predates it; defaults apply until you add it by hand (the template's commented docs show the shape).`;
253
+ //#endregion
214
254
  //#region src/config/pointer.schema.ts
215
255
  const pointerScalar = z.union([
216
256
  z.string(),
@@ -229,6 +269,7 @@ Object.keys(pointerFrontmatterSchema.shape);
229
269
  //#region src/paths/claude-paths.constant.ts
230
270
  const CLAUDE_DIR = ".claude";
231
271
  const STATE_DIR = join(CLAUDE_DIR, "state");
272
+ const TASKS_DIR = join(STATE_DIR, "tasks");
232
273
  const SKILLS_DIR = join(CLAUDE_DIR, "skills");
233
274
  const AGENTS_DIR = join(CLAUDE_DIR, "agents");
234
275
  const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
@@ -542,6 +583,11 @@ const FLAG_LABELS = [
542
583
  name: "harness:needs-design",
543
584
  color: "d4548d",
544
585
  description: "Task touches UI; a design handoff (ui-handoff.md) is owed before spec-ready."
586
+ },
587
+ {
588
+ name: "harness:partition-plan",
589
+ color: "b4a8ff",
590
+ description: "Partition plan of a feature: the approved cut + status of its parts. Never a task itself."
545
591
  }
546
592
  ];
547
593
  const DISCOVERY_LABELS = [
@@ -2037,6 +2083,544 @@ const stampBaseline = (parsed, hash) => {
2037
2083
  };
2038
2084
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2039
2085
  //#endregion
2086
+ //#region src/review-ledger/review-ledger.constant.ts
2087
+ const REVIEW_LEDGER_DIRNAME = "review-ledger";
2088
+ const FULL_PASS_FILENAME = "full-pass.json";
2089
+ const FULL_PASS_STEP = "full-pass";
2090
+ const CRITERION_ID = /^[RT]\d+$/;
2091
+ const SPEC_SIDE_KINDS = [
2092
+ "spec-missing",
2093
+ "group-missing",
2094
+ "group-empty",
2095
+ "duplicate-group-index",
2096
+ "orphan-task",
2097
+ "malformed-risk-marker",
2098
+ "malformed-task-refs",
2099
+ "unknown-risk-class",
2100
+ "dangling-requirement-ref"
2101
+ ];
2102
+ const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
2103
+ const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2104
+ const RISK_MARKER_ALL = /\[risk:/gi;
2105
+ const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2106
+ const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2107
+ const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2108
+ const TASK_REFS_LOOKALIKE = /\(\s*R\d+/;
2109
+ const TASK_REFS_AFTER_TITLE = /(?:\*\*|__)\s*(\(\s*R\d+)/;
2110
+ const REQUIREMENT_LINE = /^\s*-\s*\*\*(R\d+)\*\*/;
2111
+ const TEST_FILE = /(^|\/)__tests__\/|\.(spec|test)\.[cm]?[jt]sx?$/;
2112
+ //#endregion
2113
+ //#region src/review-ledger/review-ledger.model.ts
2114
+ const RISK_CLASSES = [
2115
+ "auth",
2116
+ "payments",
2117
+ "shell-process",
2118
+ "data-loss",
2119
+ "secrets",
2120
+ "executable-mode"
2121
+ ];
2122
+ const MUTANT_OUTCOMES = ["killed", "survived"];
2123
+ const NOT_APPLICABLE_REASONS = [
2124
+ "no-mutable-logic",
2125
+ "change-without-logic",
2126
+ "generated",
2127
+ "outside-declared-risk"
2128
+ ];
2129
+ //#endregion
2130
+ //#region src/review-ledger/parse-spec.ts
2131
+ const parseTasksSpec = (text) => {
2132
+ const lines = text.split(/\r?\n/);
2133
+ const grouped = lines.some((line) => GROUP_HEADER.test(line));
2134
+ const groups = [];
2135
+ const orphanTaskIds = [];
2136
+ const malformedRiskHeaders = [];
2137
+ const malformedTaskRefs = [];
2138
+ let current;
2139
+ for (let index = 0; index < lines.length; index += 1) {
2140
+ const line = lines[index] ?? "";
2141
+ const header = GROUP_HEADER.exec(line);
2142
+ if (header) {
2143
+ const risk = parseRiskMarker(line);
2144
+ if (risk.malformed) malformedRiskHeaders.push(line.trim());
2145
+ current = {
2146
+ index: Number(header[1]),
2147
+ header: line.trim(),
2148
+ riskClasses: risk.riskClasses,
2149
+ unknownRiskClasses: risk.unknownRiskClasses,
2150
+ tasks: []
2151
+ };
2152
+ groups.push(current);
2153
+ continue;
2154
+ }
2155
+ const match = TASK_LINE.exec(line);
2156
+ if (!match) continue;
2157
+ const blockEnd = taskBlockEnd(lines, index);
2158
+ const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2159
+ index = blockEnd - 1;
2160
+ const task = parseTaskBlock(match[1] ?? "", parts);
2161
+ if (task.malformedRefs) malformedTaskRefs.push(task.id);
2162
+ if (current) {
2163
+ current.tasks.push(task.task);
2164
+ continue;
2165
+ }
2166
+ if (grouped) {
2167
+ orphanTaskIds.push(task.id);
2168
+ continue;
2169
+ }
2170
+ groups.push({
2171
+ index: groups.length + 1,
2172
+ header: `(ungrouped) ${task.id}`,
2173
+ riskClasses: [],
2174
+ unknownRiskClasses: [],
2175
+ tasks: [task.task]
2176
+ });
2177
+ }
2178
+ const seen = /* @__PURE__ */ new Set();
2179
+ const duplicateIndexes = [];
2180
+ for (const group of groups) {
2181
+ if (seen.has(group.index) && !duplicateIndexes.includes(group.index)) duplicateIndexes.push(group.index);
2182
+ seen.add(group.index);
2183
+ }
2184
+ return {
2185
+ groups,
2186
+ orphanTaskIds,
2187
+ duplicateIndexes,
2188
+ malformedRiskHeaders,
2189
+ malformedTaskRefs
2190
+ };
2191
+ };
2192
+ const taskBlockEnd = (lines, start) => {
2193
+ let end = start + 1;
2194
+ while (end < lines.length) {
2195
+ const line = lines[end] ?? "";
2196
+ if (line.trim() === "" || !/^\s/.test(line) || TASK_LINE.test(line) || GROUP_HEADER.test(line)) break;
2197
+ end += 1;
2198
+ }
2199
+ return end;
2200
+ };
2201
+ const parseTaskBlock = (id, parts) => {
2202
+ const block = parts.join(" ");
2203
+ const opener = refsOpener(parts, block);
2204
+ const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
2205
+ const requirementRefs = refs ? (refs[1] ?? "").split(",").map((ref) => ref.trim()).filter(Boolean) : [];
2206
+ return {
2207
+ id,
2208
+ task: {
2209
+ id,
2210
+ requirementRefs: [...new Set(requirementRefs)]
2211
+ },
2212
+ malformedRefs: refs === null && TASK_REFS_LOOKALIKE.test(block)
2213
+ };
2214
+ };
2215
+ const declarationAt = (parts, block, opener) => {
2216
+ const candidate = TASK_REFS.exec(block.slice(opener));
2217
+ if (!candidate) return null;
2218
+ return declarationClosesLine(parts, opener + candidate[0].length) ? candidate : null;
2219
+ };
2220
+ const declarationClosesLine = (parts, end) => {
2221
+ const lineEnds = /* @__PURE__ */ new Set();
2222
+ let offset = 0;
2223
+ for (const part of parts) {
2224
+ offset += part.length;
2225
+ lineEnds.add(offset);
2226
+ offset += 1;
2227
+ }
2228
+ if (lineEnds.has(end)) return true;
2229
+ const tail = parts.join(" ").slice(end);
2230
+ return /^(?:\*\*|__)/.test(tail) && lineEnds.has(end + 2);
2231
+ };
2232
+ const refsOpener = (parts, block) => {
2233
+ const candidates = [];
2234
+ const onFirstLine = TASK_REFS_LOOKALIKE.exec(parts[0] ?? "")?.index;
2235
+ if (onFirstLine !== void 0) candidates.push(onFirstLine);
2236
+ const afterTitle = TASK_REFS_AFTER_TITLE.exec(block);
2237
+ if (afterTitle) candidates.push(afterTitle.index + afterTitle[0].lastIndexOf("("));
2238
+ let offset = (parts[0] ?? "").length + 1;
2239
+ for (const part of parts.slice(1)) {
2240
+ if (/^\(\s*R\d+/.test(part)) candidates.push(offset);
2241
+ offset += part.length + 1;
2242
+ }
2243
+ return candidates.length === 0 ? void 0 : Math.min(...candidates);
2244
+ };
2245
+ const parseRiskMarker = (line) => {
2246
+ const marker = RISK_MARKER.exec(line);
2247
+ const markerCount = (line.match(RISK_MARKER_ALL) ?? []).length;
2248
+ if (!marker) return {
2249
+ riskClasses: [],
2250
+ unknownRiskClasses: [],
2251
+ malformed: RISK_MARKER_LOOKALIKE.test(line)
2252
+ };
2253
+ const riskClasses = [];
2254
+ const unknownRiskClasses = [];
2255
+ for (const raw of (marker[1] ?? "").split(",")) {
2256
+ const tag = raw.trim();
2257
+ if (tag === "") continue;
2258
+ if (RISK_CLASSES.includes(tag)) {
2259
+ if (!riskClasses.includes(tag)) riskClasses.push(tag);
2260
+ continue;
2261
+ }
2262
+ if (!unknownRiskClasses.includes(tag)) unknownRiskClasses.push(tag);
2263
+ }
2264
+ return {
2265
+ riskClasses,
2266
+ unknownRiskClasses,
2267
+ malformed: riskClasses.length === 0 && unknownRiskClasses.length === 0 || markerCount > 1
2268
+ };
2269
+ };
2270
+ const parseRequirementIds = (text) => {
2271
+ const ids = [];
2272
+ for (const line of text.split(/\r?\n/)) {
2273
+ const id = REQUIREMENT_LINE.exec(line)?.[1];
2274
+ if (id && !ids.includes(id)) ids.push(id);
2275
+ }
2276
+ return ids;
2277
+ };
2278
+ const sliceForGroups = (groups) => {
2279
+ const requirementIds = [];
2280
+ const unreferencedTaskIds = [];
2281
+ for (const group of groups) for (const task of group.tasks) {
2282
+ if (task.requirementRefs.length === 0) {
2283
+ if (!unreferencedTaskIds.includes(task.id)) unreferencedTaskIds.push(task.id);
2284
+ continue;
2285
+ }
2286
+ for (const ref of task.requirementRefs) if (!requirementIds.includes(ref)) requirementIds.push(ref);
2287
+ }
2288
+ const basis = requirementIds.length > 0 && unreferencedTaskIds.length > 0 ? "mixed" : requirementIds.length > 0 ? "requirements" : "tasks";
2289
+ return {
2290
+ ids: [...requirementIds, ...unreferencedTaskIds],
2291
+ basis
2292
+ };
2293
+ };
2294
+ //#endregion
2295
+ //#region src/review-ledger/review-ledger.schema.ts
2296
+ const prose = z.string().trim().min(1);
2297
+ const criterionId = z.string().regex(CRITERION_ID);
2298
+ const criterionSchema = z.object({
2299
+ id: criterionId,
2300
+ evidence: prose
2301
+ }).strict();
2302
+ const gateSchema = z.discriminatedUnion("kind", [z.object({
2303
+ kind: z.literal("script"),
2304
+ script: prose,
2305
+ evidence: prose
2306
+ }).strict(), z.object({
2307
+ kind: z.literal("real-run"),
2308
+ evidence: prose
2309
+ }).strict()]);
2310
+ const probeSchema = z.object({
2311
+ mutation: prose,
2312
+ outcome: z.enum(MUTANT_OUTCOMES),
2313
+ killedBy: prose.optional()
2314
+ }).strict();
2315
+ const mutantFileSchema = z.discriminatedUnion("status", [z.object({
2316
+ file: prose,
2317
+ status: z.literal("probed"),
2318
+ probes: z.array(probeSchema).min(1)
2319
+ }).strict(), z.object({
2320
+ file: prose,
2321
+ status: z.literal("not-applicable"),
2322
+ reason: z.enum(NOT_APPLICABLE_REASONS),
2323
+ note: prose
2324
+ }).strict()]);
2325
+ const mutantsSchema = z.discriminatedUnion("basis", [z.object({ basis: z.literal("no-declared-risk") }).strict(), z.object({
2326
+ basis: z.literal("declared-risk"),
2327
+ files: z.array(mutantFileSchema)
2328
+ }).strict()]);
2329
+ const ledgerStepSchema = z.union([z.int().min(1), z.literal(FULL_PASS_STEP)]);
2330
+ const reviewLedgerSchema = z.object({
2331
+ version: z.literal(1),
2332
+ step: ledgerStepSchema,
2333
+ criteria: z.array(criterionSchema),
2334
+ gates: z.array(gateSchema),
2335
+ mutants: mutantsSchema
2336
+ }).strict();
2337
+ //#endregion
2338
+ //#region src/review-ledger/validate-ledger.ts
2339
+ const runLedgerValidate = async (inputs) => {
2340
+ const { address } = inputs;
2341
+ const problems = [];
2342
+ const taskRel = join(TASKS_DIR, inputs.taskId);
2343
+ const tasksRel = join(taskRel, "spec", "tasks.md");
2344
+ const requirementsPath = join(inputs.repoRoot, taskRel, "spec", "requirements.md");
2345
+ const ledgerPath = reviewLedgerPath(inputs.taskId, address);
2346
+ const owedFiles = await collectOwedFiles(inputs);
2347
+ const declaredGates = await collectDeclaredGates(inputs.repoRoot);
2348
+ const result = {
2349
+ ok: false,
2350
+ address,
2351
+ ledgerPath,
2352
+ criteria: {
2353
+ required: [],
2354
+ present: []
2355
+ },
2356
+ gates: owedGates(declaredGates),
2357
+ unknownRiskClasses: [],
2358
+ problems
2359
+ };
2360
+ if (!await pathExists(join(inputs.repoRoot, tasksRel))) {
2361
+ problems.push({
2362
+ kind: "spec-missing",
2363
+ message: `No ${tasksRel} — the criteria class is enumerated from the spec's groups, so there is nothing to validate against.`
2364
+ });
2365
+ return result;
2366
+ }
2367
+ const spec = parseTasksSpec(await readFile(join(inputs.repoRoot, tasksRel), "utf8"));
2368
+ for (const index of spec.duplicateIndexes) problems.push({
2369
+ kind: "duplicate-group-index",
2370
+ message: `tasks.md declares "## Group ${index}" more than once. A step resolves to the first, so every later group of that number is never reviewed.`,
2371
+ subject: String(index)
2372
+ });
2373
+ if (spec.orphanTaskIds.length > 0) problems.push({
2374
+ kind: "orphan-task",
2375
+ message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
2376
+ });
2377
+ for (const header of spec.malformedRiskHeaders) problems.push({
2378
+ kind: "malformed-risk-marker",
2379
+ message: `"${header}" carries a risk token that is not a parseable "[risk: <class>]" marker at end of line. Left as-is it reads as no declared risk.`,
2380
+ subject: header
2381
+ });
2382
+ for (const id of spec.malformedTaskRefs) problems.push({
2383
+ kind: "malformed-task-refs",
2384
+ message: `${id} carries a "(R<n>"-shaped tail that is not a well-formed "(R1, R2)" ref list. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
2385
+ subject: id
2386
+ });
2387
+ const groups = resolveGroups(spec.groups, address, problems);
2388
+ if (groups === void 0) return result;
2389
+ if (address.kind === "step") result.groupHeader = groups[0]?.header;
2390
+ for (const group of groups) {
2391
+ if (group.tasks.length === 0) problems.push({
2392
+ kind: "group-empty",
2393
+ message: `Group ${group.index} carries no tasks — an empty group is a broken spec, not a trivial step.`,
2394
+ subject: group.header
2395
+ });
2396
+ for (const unknown of group.unknownRiskClasses) {
2397
+ if (!result.unknownRiskClasses.includes(unknown)) result.unknownRiskClasses.push(unknown);
2398
+ problems.push({
2399
+ kind: "unknown-risk-class",
2400
+ message: `Group ${group.index} declares risk class "${unknown}", which is not in the vocabulary. A class outside it is either a typo or a surface that has not earned a name yet — both are spec defects, and neither is the Reviewer's to fix.`,
2401
+ subject: unknown
2402
+ });
2403
+ }
2404
+ }
2405
+ const slice = sliceForGroups(groups);
2406
+ result.basis = slice.basis;
2407
+ const dangling = await collectDanglingRefs(requirementsPath, slice.ids, problems);
2408
+ const required = slice.ids.filter((id) => !dangling.includes(id));
2409
+ result.criteria = {
2410
+ required,
2411
+ present: []
2412
+ };
2413
+ const ledger = await readLedger(join(inputs.repoRoot, ledgerPath), ledgerPath, problems);
2414
+ if (ledger === void 0) return result;
2415
+ checkAddress(ledger, address, ledgerPath, problems);
2416
+ result.criteria = checkCriteria(required, dangling, ledger, problems);
2417
+ result.gates = checkGates(ledger, result.gates, problems);
2418
+ checkMutantsBasis(ledger, groups, ledgerPath, problems);
2419
+ checkMutantsFloor(ledger, owedFiles, inputs.anchor, problems);
2420
+ result.ok = problems.length === 0;
2421
+ return result;
2422
+ };
2423
+ const reviewLedgerPath = (taskId, address) => join(TASKS_DIR, taskId, REVIEW_LEDGER_DIRNAME, address.kind === "full-pass" ? FULL_PASS_FILENAME : `step-${address.step}.json`);
2424
+ const resolveGroups = (groups, address, problems) => {
2425
+ if (address.kind === "full-pass") {
2426
+ if (groups.length > 0) return groups;
2427
+ problems.push({
2428
+ kind: "group-missing",
2429
+ message: `tasks.md declares no group and no task, so the full pass has no slice to validate against.`
2430
+ });
2431
+ return;
2432
+ }
2433
+ const group = groups.find((candidate) => candidate.index === address.step);
2434
+ if (group) return [group];
2435
+ problems.push({
2436
+ kind: "group-missing",
2437
+ message: `tasks.md declares ${groups.length} group(s); step ${address.step} has none. One step is one group.`
2438
+ });
2439
+ };
2440
+ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2441
+ const referenced = sliceIds.filter((id) => id.startsWith("R"));
2442
+ if (referenced.length === 0) return [];
2443
+ if (!await pathExists(requirementsPath)) {
2444
+ problems.push({
2445
+ kind: "dangling-requirement-ref",
2446
+ message: `The slice references ${referenced.join(", ")} but there is no requirements.md to resolve them against.`
2447
+ });
2448
+ return referenced;
2449
+ }
2450
+ const declared = parseRequirementIds(await readFile(requirementsPath, "utf8"));
2451
+ const dangling = referenced.filter((id) => !declared.includes(id));
2452
+ for (const id of dangling) problems.push({
2453
+ kind: "dangling-requirement-ref",
2454
+ message: `The slice references ${id}, which requirements.md does not declare.`,
2455
+ subject: id
2456
+ });
2457
+ return dangling;
2458
+ };
2459
+ const readLedger = async (absolutePath, ledgerPath, problems) => {
2460
+ if (!await pathExists(absolutePath)) {
2461
+ problems.push({
2462
+ kind: "ledger-missing",
2463
+ message: `No ${ledgerPath} — the Reviewer writes it beside its verdict; an APPROVE without a ledger is structurally invalid.`
2464
+ });
2465
+ return;
2466
+ }
2467
+ let parsed;
2468
+ try {
2469
+ parsed = JSON.parse(await readFile(absolutePath, "utf8"));
2470
+ } catch (error) {
2471
+ problems.push({
2472
+ kind: "ledger-unparseable",
2473
+ message: `${ledgerPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}.`
2474
+ });
2475
+ return;
2476
+ }
2477
+ const checked = reviewLedgerSchema.safeParse(parsed);
2478
+ if (checked.success) return checked.data;
2479
+ for (const issue of checked.error.issues) {
2480
+ const at = formatPath(issue.path);
2481
+ problems.push({
2482
+ kind: "ledger-shape",
2483
+ message: `${ledgerPath} does not match the schema at ${at || "<root>"}: ${issue.message}.`,
2484
+ subject: at || void 0
2485
+ });
2486
+ }
2487
+ };
2488
+ const formatPath = (path) => path.map((segment) => typeof segment === "number" ? `[${segment}]` : `.${String(segment)}`).join("").replace(/^\./, "");
2489
+ const checkAddress = (ledger, address, ledgerPath, problems) => {
2490
+ const expected = address.kind === "full-pass" ? FULL_PASS_STEP : address.step;
2491
+ if (ledger.step === expected) return;
2492
+ problems.push({
2493
+ kind: "ledger-address-mismatch",
2494
+ message: `${ledgerPath} declares "step": ${JSON.stringify(ledger.step)} but was addressed as ${JSON.stringify(expected)}.`,
2495
+ subject: String(ledger.step)
2496
+ });
2497
+ };
2498
+ const checkMutantsBasis = (ledger, groups, ledgerPath, problems) => {
2499
+ const declared = groups.filter((group) => group.riskClasses.length > 0);
2500
+ if (declared.length === 0 || ledger.mutants.basis === "declared-risk") return;
2501
+ problems.push({
2502
+ kind: "mutants-basis-mismatch",
2503
+ message: `${ledgerPath} says "mutants": {"basis": "no-declared-risk"} but ${declared.map((group) => `Group ${group.index}`).join(", ")} declares [risk: ${[...new Set(declared.flatMap((group) => group.riskClasses))].join(", ")}] — the record must carry "basis": "declared-risk" with its files.`,
2504
+ subject: ledger.mutants.basis
2505
+ });
2506
+ };
2507
+ const gitFailureDetail = (result, verb) => result.stderr.trim().split("\n")[0] || `git ${verb} exited ${result.code}`;
2508
+ const collectOwedFiles = async (inputs) => {
2509
+ const resolved = await inputs.runCommand("git", [
2510
+ "-C",
2511
+ inputs.repoRoot,
2512
+ "rev-parse",
2513
+ "--verify",
2514
+ "--end-of-options",
2515
+ `${inputs.anchor}^{commit}`
2516
+ ]);
2517
+ if (resolved.code !== 0) throw new Error(`Invalid --anchor="${inputs.anchor}": ${gitFailureDetail(resolved, "rev-parse")}`);
2518
+ const oid = resolved.stdout.trim();
2519
+ const listChanged = async (cached) => {
2520
+ const diff = await inputs.runCommand("git", [
2521
+ "-C",
2522
+ inputs.repoRoot,
2523
+ "diff",
2524
+ ...cached ? ["--cached"] : [],
2525
+ "--name-only",
2526
+ "-z",
2527
+ "--diff-filter=d",
2528
+ oid,
2529
+ "--",
2530
+ ":(exclude).claude/state"
2531
+ ]);
2532
+ if (diff.code !== 0) throw new Error(`git diff against --anchor="${inputs.anchor}" failed: ${gitFailureDetail(diff, "diff")}`);
2533
+ return diff.stdout.split("\0").filter((file) => file.length > 0);
2534
+ };
2535
+ const worktree = await listChanged(false);
2536
+ const staged = await listChanged(true);
2537
+ return [.../* @__PURE__ */ new Set([...worktree, ...staged])].filter((file) => !TEST_FILE.test(file));
2538
+ };
2539
+ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
2540
+ if (ledger.mutants.basis !== "declared-risk") return;
2541
+ const recorded = new Set(ledger.mutants.files.map((entry) => entry.file));
2542
+ for (const file of owedFiles) {
2543
+ if (recorded.has(file)) continue;
2544
+ problems.push({
2545
+ kind: "unaccounted-file",
2546
+ message: `${file} changed in the diff against ${anchor} and has no "mutants"."files" entry — probe it, or record it as "not-applicable" with its reason and note.`,
2547
+ subject: file
2548
+ });
2549
+ }
2550
+ };
2551
+ const collectDeclaredGates = async (repoRoot) => {
2552
+ if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
2553
+ return (await readHarnessConfig(repoRoot)).gates;
2554
+ };
2555
+ const configEntryExists = async (path) => {
2556
+ try {
2557
+ await lstat(path);
2558
+ return true;
2559
+ } catch (error) {
2560
+ if (error.code === "ENOENT") return false;
2561
+ throw error;
2562
+ }
2563
+ };
2564
+ const owedGates = (declared) => ({
2565
+ basis: declared === void 0 ? "undeclared" : "config",
2566
+ required: [...new Set(declared ?? [])],
2567
+ present: []
2568
+ });
2569
+ const checkGates = (ledger, owed, problems) => {
2570
+ const attested = new Set(ledger.gates.filter((gate) => gate.kind === "script").map((gate) => gate.script));
2571
+ for (const name of owed.required) {
2572
+ if (attested.has(name)) continue;
2573
+ problems.push({
2574
+ kind: "gate-unattested",
2575
+ message: `${HARNESS_CONFIG_FILENAME} declares gate "${name}" and the ledger has no {"kind": "script", "script": "${name}"} entry — run it and record what came out (a gate that failed, or no longer exists, is still attested, with evidence saying so).`,
2576
+ subject: name
2577
+ });
2578
+ }
2579
+ if (!ledger.gates.some((gate) => gate.kind === "real-run")) problems.push({
2580
+ kind: "real-run-missing",
2581
+ message: `The ledger has no {"kind": "real-run"} entry — the real run is the floor every review owes, declared gates or none.`
2582
+ });
2583
+ return {
2584
+ ...owed,
2585
+ present: owed.required.filter((name) => attested.has(name))
2586
+ };
2587
+ };
2588
+ const checkCriteria = (required, dangling, ledger, problems) => {
2589
+ const present = [];
2590
+ for (const entry of ledger.criteria) {
2591
+ if (dangling.includes(entry.id)) continue;
2592
+ if (!required.includes(entry.id)) {
2593
+ problems.push({
2594
+ kind: "criteria-unexpected",
2595
+ message: `"${entry.id}" is not in the slice (${required.join(", ") || "empty"}). An entry for something the review does not cover is not evidence.`,
2596
+ subject: entry.id
2597
+ });
2598
+ continue;
2599
+ }
2600
+ if (present.includes(entry.id)) {
2601
+ problems.push({
2602
+ kind: "criteria-duplicated",
2603
+ message: `${entry.id} has more than one entry; one criterion, one entry.`,
2604
+ subject: entry.id
2605
+ });
2606
+ continue;
2607
+ }
2608
+ present.push(entry.id);
2609
+ }
2610
+ for (const id of required) {
2611
+ if (present.includes(id)) continue;
2612
+ problems.push({
2613
+ kind: "criteria-missing",
2614
+ message: `${id} is in the slice and has no criteria entry.`,
2615
+ subject: id
2616
+ });
2617
+ }
2618
+ return {
2619
+ required,
2620
+ present
2621
+ };
2622
+ };
2623
+ //#endregion
2040
2624
  //#region src/spinoff/spinoff.constant.ts
2041
2625
  const MANAGED_LABEL = "harness:managed";
2042
2626
  const PENDING_STATUS_LABEL = "harness:status:pending";
@@ -2810,7 +3394,6 @@ const inspectDevDependency = async (repoRoot) => {
2810
3394
  //#endregion
2811
3395
  //#region src/scan/scan.constant.ts
2812
3396
  const ARCHITECTURE_DOC_PATH = "docs/architecture.md";
2813
- const MUTATION_SCRIPT_NAME = "test:mutation";
2814
3397
  //#endregion
2815
3398
  //#region src/scan/scan.ts
2816
3399
  const ORIGIN_URL = /\[remote "origin"\][^[]*?url\s*=\s*(\S+)/;
@@ -2831,23 +3414,14 @@ const readOriginSlug = async (root) => {
2831
3414
  return null;
2832
3415
  }
2833
3416
  };
2834
- const hasMutationScript = async (root) => {
2835
- try {
2836
- const script = JSON.parse(await readFile(join(root, "package.json"), "utf8")).scripts?.[MUTATION_SCRIPT_NAME];
2837
- return typeof script === "string" && script.trim().length > 0;
2838
- } catch {
2839
- return false;
2840
- }
2841
- };
2842
3417
  const scanRepo = async (root) => {
2843
- const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc, hasMutationTesting] = await Promise.all([
3418
+ const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc] = await Promise.all([
2844
3419
  pathExists(join(root, ".git")),
2845
3420
  pathExists(join(root, "CLAUDE.md")),
2846
3421
  pathExists(join(root, "CONTEXT.md")),
2847
3422
  pathExists(join(root, "docs")),
2848
3423
  pathExists(join(root, "package.json")),
2849
- pathExists(join(root, ARCHITECTURE_DOC_PATH)),
2850
- hasMutationScript(root)
3424
+ pathExists(join(root, ARCHITECTURE_DOC_PATH))
2851
3425
  ]);
2852
3426
  return {
2853
3427
  isGitRepo,
@@ -2856,8 +3430,7 @@ const scanRepo = async (root) => {
2856
3430
  hasContextMd,
2857
3431
  hasDocs,
2858
3432
  hasPackageJson,
2859
- hasArchitectureDoc,
2860
- hasMutationTesting
3433
+ hasArchitectureDoc
2861
3434
  };
2862
3435
  };
2863
3436
  //#endregion
@@ -2899,18 +3472,11 @@ const PHASES = [
2899
3472
  ];
2900
3473
  //#endregion
2901
3474
  //#region src/skills/skills.ts
2902
- const CAPABILITY_REGISTRY = {
2903
- "has-architecture-doc": {
2904
- predicate: (caps) => caps.hasArchitectureDoc,
2905
- trigger: ARCHITECTURE_DOC_PATH,
2906
- label: "keep your architecture map current"
2907
- },
2908
- "has-mutation-testing": {
2909
- predicate: (caps) => caps.hasMutationTesting,
2910
- trigger: `a \`${MUTATION_SCRIPT_NAME}\` package.json script`,
2911
- label: "check test strength with mutation testing in review"
2912
- }
2913
- };
3475
+ const CAPABILITY_REGISTRY = { "has-architecture-doc": {
3476
+ predicate: (caps) => caps.hasArchitectureDoc,
3477
+ trigger: ARCHITECTURE_DOC_PATH,
3478
+ label: "keep your architecture map current"
3479
+ } };
2914
3480
  const capabilityHolds = (key, caps) => {
2915
3481
  const entry = CAPABILITY_REGISTRY[key];
2916
3482
  if (!entry) throw new Error(`unknown applies-when capability key "${key}"`);
@@ -3033,6 +3599,7 @@ const runDoctor = async (deps) => {
3033
3599
  checks.push(await checkCliResolution(deps));
3034
3600
  checks.push(await checkCapabilities(deps, config));
3035
3601
  checks.push(await checkDesignToolDrift(deps));
3602
+ checks.push(await checkHookLib(deps));
3036
3603
  return {
3037
3604
  checks,
3038
3605
  ok: checks.every((check) => check.status !== "fail")
@@ -3301,6 +3868,91 @@ const checkDesignToolDrift = async (deps) => {
3301
3868
  };
3302
3869
  }
3303
3870
  };
3871
+ const checkHookLib = async (deps) => {
3872
+ const name = "hook-lib";
3873
+ let helpers;
3874
+ try {
3875
+ helpers = (await listFiles(join(deps.vendorRoot, "hooks", "lib"))).filter((rel) => rel.endsWith(".sh")).toSorted();
3876
+ } catch (error) {
3877
+ return {
3878
+ name,
3879
+ status: "warn",
3880
+ detail: `Could not read the vendor catalog's hook lib helpers: ${error.message}`,
3881
+ remediation: "Reinstall the harness package, then re-run `lemony doctor`."
3882
+ };
3883
+ }
3884
+ if (helpers.length === 0) return {
3885
+ name,
3886
+ status: "warn",
3887
+ detail: "The vendor catalog ships no hook lib helpers — the installed package looks damaged.",
3888
+ remediation: "Reinstall the harness package, then re-run `lemony doctor`."
3889
+ };
3890
+ const problems = (await Promise.all(helpers.map(async (helper) => {
3891
+ const installed = join(deps.repoRoot, HOOKS_DIR, "lib", helper);
3892
+ let installedStat;
3893
+ try {
3894
+ installedStat = await stat(installed);
3895
+ } catch (error) {
3896
+ const code = error.code;
3897
+ if (await lstat(installed).then(() => true, () => false)) return {
3898
+ helper,
3899
+ kind: "obstructed",
3900
+ description: `${helper} is an unresolvable symlink (${code ?? "unknown error"})`
3901
+ };
3902
+ if (code === "ENOENT" || code === "ENOTDIR") return {
3903
+ helper,
3904
+ kind: "missing",
3905
+ description: `${helper} is missing`
3906
+ };
3907
+ return {
3908
+ helper,
3909
+ kind: "unreadable",
3910
+ description: `${helper} is unreadable (${code ?? "unknown error"})`
3911
+ };
3912
+ }
3913
+ if (!installedStat.isFile()) return {
3914
+ helper,
3915
+ kind: "obstructed",
3916
+ description: `${helper} is not a regular file`
3917
+ };
3918
+ if (!await isExecutable(installed)) return {
3919
+ helper,
3920
+ kind: "mode",
3921
+ description: `${helper} is not executable`
3922
+ };
3923
+ if (!await isReadable(installed)) return {
3924
+ helper,
3925
+ kind: "mode",
3926
+ description: `${helper} is not readable`
3927
+ };
3928
+ return null;
3929
+ }))).filter((problem) => problem !== null);
3930
+ if (problems.length === 0) return {
3931
+ name,
3932
+ status: "ok",
3933
+ detail: `All ${helpers.length} hook lib helpers installed with read+exec permission in ${HOOKS_DIR}/lib.`
3934
+ };
3935
+ const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
3936
+ const remediations = [];
3937
+ if (problems.some((problem) => problem.kind === "missing")) remediations.push("Run `lemony repair` to restore the missing helpers");
3938
+ if (problems.some((problem) => problem.kind === "obstructed")) remediations.push(`remove ${pathsOf("obstructed")} (\`repair\` cannot fix a non-file entry), then run \`lemony repair\``);
3939
+ if (problems.some((problem) => problem.kind === "mode")) remediations.push(`run \`chmod +rx ${pathsOf("mode")}\` to restore the read/exec bits (\`repair\` only rewrites changed content, so it cannot)`);
3940
+ if (problems.some((problem) => problem.kind === "unreadable")) remediations.push(`fix permissions so ${HOOKS_DIR}/lib and its entries are readable, then re-run \`lemony doctor\``);
3941
+ return {
3942
+ name,
3943
+ status: "warn",
3944
+ detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description).join("; ")} — agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime.`,
3945
+ remediation: `${remediations.join("; ")}.`
3946
+ };
3947
+ };
3948
+ const isReadable = async (path) => {
3949
+ try {
3950
+ await access(path, constants.R_OK);
3951
+ return true;
3952
+ } catch {
3953
+ return false;
3954
+ }
3955
+ };
3304
3956
  //#endregion
3305
3957
  //#region src/status/status.ts
3306
3958
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
@@ -3421,7 +4073,7 @@ const COMMANDS = [
3421
4073
  },
3422
4074
  {
3423
4075
  name: "discovery",
3424
- summary: "Reflect a raised/resolved discovery onto its issue (label flip + comment); used by the Orchestrator's resolve-discovery skill.",
4076
+ summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
3425
4077
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
3426
4078
  },
3427
4079
  {
@@ -3429,6 +4081,11 @@ const COMMANDS = [
3429
4081
  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).",
3430
4082
  usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
3431
4083
  },
4084
+ {
4085
+ name: "review-ledger",
4086
+ summary: "Validate the Reviewer's evidence ledger (the JSON sidecar under the task's state) against the spec's groups, the anchored diff and the declared gates: shape, criteria-vs-slice, unknown risk classes, the mutant floor, the gates floor (deterministic, agent-free; exits non-zero on an invalid ledger).",
4087
+ usage: "lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass)"
4088
+ },
3432
4089
  {
3433
4090
  name: "spinoff",
3434
4091
  summary: "Capture a non-blocking defect found mid-task as a pending stub (+ followup_captured event); used by the /spinoff command.",
@@ -3437,7 +4094,7 @@ const COMMANDS = [
3437
4094
  {
3438
4095
  name: "telemetry",
3439
4096
  summary: "Inspect and control anonymous telemetry (on by default): show what is sent, force a send, opt out, opt back in.",
3440
- usage: "lemony telemetry <status|show|flush|disable|enable> [--purge-local]"
4097
+ usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
3441
4098
  }
3442
4099
  ];
3443
4100
  //#endregion
@@ -3934,7 +4591,8 @@ const COMPANION_DOCS = [
3934
4591
  "fit-assessment",
3935
4592
  "triage",
3936
4593
  "spinoff",
3937
- "ui-design"
4594
+ "ui-design",
4595
+ "partition"
3938
4596
  ];
3939
4597
  const renderFile = async (templatePath, relPath, vars) => {
3940
4598
  return {
@@ -4274,6 +4932,7 @@ const runReconcile = async (inputs) => {
4274
4932
  const fromVersion = config.vendor_version;
4275
4933
  const target = config.target;
4276
4934
  const taskStorageRepo = config.task_storage.repo;
4935
+ const predatedKeys = findPredatedKeys(await readFile(join(repoRoot, HARNESS_CONFIG_FILENAME), "utf8"), fromVersion, toVersion, inputs.configKeySince ?? CONFIG_KEY_SINCE);
4277
4936
  const baselineVersion = await findBaselineVersion(repoRoot);
4278
4937
  const baseline = baselineVersion ? await readBaseline(repoRoot, baselineVersion) : /* @__PURE__ */ new Map();
4279
4938
  const hadBaseline = baseline.size > 0;
@@ -4310,7 +4969,8 @@ const runReconcile = async (inputs) => {
4310
4969
  hadBaseline,
4311
4970
  ...summarize(actions),
4312
4971
  labelSync: null,
4313
- lostHookCommands: []
4972
+ lostHookCommands: [],
4973
+ predatedKeys
4314
4974
  };
4315
4975
  if (hadBaseline) {
4316
4976
  await writeSnapshot(repoRoot, fromVersion, {
@@ -4328,7 +4988,8 @@ const runReconcile = async (inputs) => {
4328
4988
  hadBaseline,
4329
4989
  ...summarize(actions),
4330
4990
  labelSync,
4331
- lostHookCommands
4991
+ lostHookCommands,
4992
+ predatedKeys
4332
4993
  };
4333
4994
  };
4334
4995
  const refuseOnResidualMarkers = async (relPaths, readClient) => {
@@ -4854,6 +5515,7 @@ const update = async (args) => {
4854
5515
  const versionLine = result.fromVersion === result.toVersion ? `Lemony ${verb("re-synced", "would re-sync")} at ${result.toVersion}` : `Lemony ${verb("updated", "would update")} ${result.fromVersion} → ${result.toVersion}`;
4855
5516
  console.log(`${versionLine}${result.hadBaseline ? "" : " (no baseline — pick-one degrade)"}${suffix}.`);
4856
5517
  console.log(` ${verb("merged", "would merge")} ${result.mergedFiles.length}, ${verb("added", "would add")} ${result.addedFiles.length}, ${verb("pruned", "would prune")} ${result.prunedFiles.length}, ${verb("adopted", "would adopt")} ${result.adoptedFiles.length}.`);
5518
+ for (const key of result.predatedKeys) console.log(formatPredatedKeyLine(key));
4857
5519
  for (const { relPath, winner } of result.pickedFiles) console.log(` ${verb("picked", "would pick")} ${winner} for ${relPath} (no baseline).`);
4858
5520
  reportAdoptions(result.adoptedFiles, verb("adopted", "would adopt"));
4859
5521
  reportConflicts(result.conflictedFiles, dryRun ? "Run `lemony update` without --dry-run to write them; resolve the <<<<<<< / ======= / >>>>>>> markers afterward." : "Resolve the <<<<<<< / ======= / >>>>>>> markers; the next update refuses until they are gone.");
@@ -5003,7 +5665,7 @@ const runTelemetry = async (args) => {
5003
5665
  case "send":
5004
5666
  await telemetrySend(repoRoot);
5005
5667
  return;
5006
- default: throw new Error(`Usage: lemony telemetry <status|show|flush|disable|enable>. Unknown action "${action}".`);
5668
+ default: throw new Error(`Usage: lemony telemetry <status|show|flush|enable|disable [--purge-local]>. Unknown action "${action}".`);
5007
5669
  }
5008
5670
  };
5009
5671
  const telemetryStatus = async (repoRoot) => {
@@ -5178,6 +5840,53 @@ const designTokensExport = async (args) => {
5178
5840
  console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
5179
5841
  if (out !== void 0) console.log(` projection written to ${out}`);
5180
5842
  };
5843
+ const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";
5844
+ const reviewLedger = async (args) => {
5845
+ const action = args[0] ?? "validate";
5846
+ if (action === "validate") return reviewLedgerValidate(args);
5847
+ throw new Error(`${REVIEW_LEDGER_USAGE} Unknown action "${action}".`);
5848
+ };
5849
+ const reviewLedgerValidate = async (args) => {
5850
+ const taskId = parseFlag(args, "task-id");
5851
+ if (taskId === void 0) throw new Error(`${REVIEW_LEDGER_USAGE} --task-id is required.`);
5852
+ if (taskId !== basename(taskId) || taskId === "." || taskId === "..") throw new Error(`Invalid --task-id="${taskId}": a task id names one directory under the task state, never a path.`);
5853
+ const anchor = parseFlag(args, "anchor");
5854
+ if (anchor === void 0) throw new Error(`${REVIEW_LEDGER_USAGE} --anchor is required — the group's recorded head OID on a step, the fingerprint merge-base on a full pass (auto-commit OFF's pre-gate pass anchors at the recorded group anchor instead); the owed set of the mutant floor is the diff from it.`);
5855
+ const address = parseLedgerAddress(args);
5856
+ const result = await runLedgerValidate({
5857
+ repoRoot: cwd(),
5858
+ taskId,
5859
+ address,
5860
+ anchor,
5861
+ runCommand: makeRunCommand()
5862
+ });
5863
+ const where = address.kind === "full-pass" ? "full pass" : `step ${address.step}`;
5864
+ const counts = `${result.basis === void 0 ? "no slice enumerated" : `criteria ${result.criteria.present.length}/${result.criteria.required.length} (basis: ${result.basis})`}, gates ${result.gates.present.length}/${result.gates.required.length} (basis: ${result.gates.basis})`;
5865
+ if (address.kind === "step" && result.groupHeader !== void 0) console.log(`Step ${address.step} → ${result.groupHeader}`);
5866
+ if (result.ok) {
5867
+ console.log(`Ledger valid for ${where} (${result.ledgerPath}). ${counts}`);
5868
+ return;
5869
+ }
5870
+ console.error(`Ledger invalid for ${where} (${result.ledgerPath}): ${result.problems.length} problem(s). ${counts}`);
5871
+ for (const problem of result.problems) console.error(` [${problem.kind}] ${problem.message}`);
5872
+ const specSide = result.problems.filter((problem) => SPEC_SIDE_KINDS.includes(problem.kind)).length;
5873
+ if (specSide > 0) console.error(` ${specSide} spec-side problem(s) (tasks.md / requirements.md / the address) — not the Reviewer's to fix; do not retry the Reviewer while any of these stands.`);
5874
+ exit(1);
5875
+ };
5876
+ const parseLedgerAddress = (args) => {
5877
+ const step = parseFlag(args, "step");
5878
+ const fullPass = args.includes("--full-pass");
5879
+ if (args.some((arg) => arg.startsWith("--full-pass="))) throw new Error(`${REVIEW_LEDGER_USAGE} --full-pass takes no value.`);
5880
+ if (fullPass && step !== void 0) throw new Error(`${REVIEW_LEDGER_USAGE} --step and --full-pass are mutually exclusive.`);
5881
+ if (fullPass) return { kind: "full-pass" };
5882
+ if (step === void 0) throw new Error(`${REVIEW_LEDGER_USAGE} One of --step or --full-pass is required.`);
5883
+ const stepNumber = Number(step);
5884
+ if (!/^[1-9]\d*$/.test(step) || !Number.isSafeInteger(stepNumber)) throw new Error(`Invalid --step="${step}": a step is the 1-based index of a tasks.md group.`);
5885
+ return {
5886
+ kind: "step",
5887
+ step: stepNumber
5888
+ };
5889
+ };
5181
5890
  const spinoff = async (args) => {
5182
5891
  const harnessVersion = await readHarnessVersion();
5183
5892
  const result = await runSpinoff({
@@ -5307,6 +6016,9 @@ const main = async () => {
5307
6016
  case "design-tokens":
5308
6017
  await designTokens(args);
5309
6018
  return;
6019
+ case "review-ledger":
6020
+ await reviewLedger(args);
6021
+ return;
5310
6022
  case "spinoff":
5311
6023
  await spinoff(args);
5312
6024
  return;