@lemoncode/lemony 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +19 -14
  2. package/catalog/VERSION +1 -1
  3. package/catalog/agents/architect.md +13 -4
  4. package/catalog/agents/implementer.md +87 -8
  5. package/catalog/agents/orchestrator.md +643 -386
  6. package/catalog/agents/partition.md +316 -0
  7. package/catalog/agents/reviewer.md +356 -21
  8. package/catalog/agents/spec-author.md +16 -4
  9. package/catalog/agents/spinoff.md +100 -0
  10. package/catalog/agents/triage.md +41 -0
  11. package/catalog/agents/ui-design.md +147 -0
  12. package/catalog/agents/ui-designer.md +3 -2
  13. package/catalog/commands/add-capability.md +4 -4
  14. package/catalog/commands/define.md +7 -0
  15. package/catalog/commands/hotfix.md +15 -1
  16. package/catalog/commands/pause.md +5 -0
  17. package/catalog/commands/resume.md +38 -10
  18. package/catalog/commands/triage.md +4 -3
  19. package/catalog/harness.config.schema.json +40 -0
  20. package/catalog/hooks/lib/merge-pr.sh +699 -0
  21. package/catalog/schemas/tier2-events-history.md +17 -0
  22. package/catalog/schemas/tier2-events.md +10 -10
  23. package/catalog/skills/mutation-testing/SKILL.md +80 -19
  24. package/catalog/skills/prd-to-spec/SKILL.md +74 -2
  25. package/catalog/skills/raise-discovery/SKILL.md +6 -0
  26. package/catalog/skills/resolve-discovery/SKILL.md +12 -7
  27. package/catalog/skills/security-review/SKILL.md +119 -6
  28. package/catalog/skills/spec-compliance-check/SKILL.md +8 -4
  29. package/catalog/skills/spec-to-issue/SKILL.md +7 -1
  30. package/catalog/skills/task-closeout/SKILL.md +85 -20
  31. package/catalog/skills/test-gap-report/SKILL.md +4 -0
  32. package/catalog/skills/triage-issue/SKILL.md +65 -4
  33. package/catalog/skills/verify/SKILL.md +3 -0
  34. package/catalog/templates/claude-code/agents.md.tpl +50 -16
  35. package/catalog/templates/claude-code/harness.config.yml.tpl +33 -0
  36. package/dist/cli.mjs +744 -37
  37. package/package.json +10 -6
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";
@@ -24,10 +24,17 @@ const DEPRECATED_PATHS_KEYS = [
24
24
  "agents"
25
25
  ];
26
26
  const HARNESS_CONFIG_SCHEMA_FILENAME = "harness.config.schema.json";
27
+ const CONFIG_KEY_SINCE = { gates: "0.3.0" };
27
28
  const TASK_STORAGE_REPO_PLACEHOLDER = "OWNER/REPO";
28
29
  const TARGETS = ["claude-code"];
29
30
  const TASK_STORAGE_TYPES = ["github"];
30
31
  const TASK_STORAGE_REPO_PATTERN = /^[^\s/]+\/[^\s/]+$/;
32
+ const PRE_COMMIT_REVIEW_MODES = [
33
+ "human",
34
+ "on",
35
+ "off"
36
+ ];
37
+ const PRE_COMMIT_REVIEW_DEFAULT = "human";
31
38
  const VENDOR_VERSION_REGEX = /^\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$/;
32
39
  const VENDOR_VERSION_EXAMPLE = "0.1.0-alpha.0";
33
40
  const PATHS_DEFAULTS = {
@@ -110,6 +117,12 @@ const taskStorageSchema = z.object({
110
117
  const rollbackSchema = z.object({ keep_snapshots: z.union([z.int().positive(), z.literal("unlimited")]).default(3) }).strict().prefault({});
111
118
  const telemetrySchema = z.object({ enabled: z.boolean().default(true) }).strict().prefault({});
112
119
  const designTokensSchema = z.object({ scan_extensions: z.array(z.string()).default([]) }).strict().prefault({});
120
+ const mergeSchema = z.object({
121
+ checks_timeout_secs: z.int().positive().default(600),
122
+ allow_no_checks: z.boolean().default(false)
123
+ }).strict().prefault({});
124
+ const implementationSchema = z.object({ pre_commit_review: z.enum(PRE_COMMIT_REVIEW_MODES).default(PRE_COMMIT_REVIEW_DEFAULT) }).strict().prefault({});
125
+ const gatesSchema = z.array(z.string().trim().min(1)).optional();
113
126
  const harnessConfigSchema = z.object({
114
127
  vendor_version: z.string().regex(VENDOR_VERSION_REGEX),
115
128
  target: z.enum(TARGETS),
@@ -117,7 +130,10 @@ const harnessConfigSchema = z.object({
117
130
  paths: pathsSchema,
118
131
  rollback: rollbackSchema,
119
132
  telemetry: telemetrySchema,
120
- design_tokens: designTokensSchema
133
+ design_tokens: designTokensSchema,
134
+ merge: mergeSchema,
135
+ implementation: implementationSchema,
136
+ gates: gatesSchema
121
137
  }).strict();
122
138
  //#endregion
123
139
  //#region src/config/config.ts
@@ -128,7 +144,7 @@ const readHarnessConfig = async (repoRoot) => {
128
144
  raw = await readFile(configPath, "utf8");
129
145
  } catch (cause) {
130
146
  if (cause.code === "ENOENT") throw new Error(`${HARNESS_CONFIG_FILENAME} not found at ${repoRoot}. Run \`lemony install\` first.`, { cause });
131
- throw cause;
147
+ throw new Error(`Cannot read ${HARNESS_CONFIG_FILENAME} at ${repoRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
132
148
  }
133
149
  let parsed;
134
150
  try {
@@ -198,7 +214,10 @@ const compareVendorVersion = (a, b) => {
198
214
  //#region src/config/write-config.ts
199
215
  const setConfigValues = (rawYaml, updates) => {
200
216
  const doc = parseDocument(rawYaml);
201
- for (const [key, value] of Object.entries(updates)) if (doc.has(key)) doc.set(key, value);
217
+ for (const [key, value] of Object.entries(updates)) {
218
+ 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.`);
219
+ doc.set(key, value);
220
+ }
202
221
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
203
222
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
204
223
  const pathsNode = doc.get("paths", true);
@@ -211,6 +230,16 @@ const writeConfigValues = async (repoRoot, updates) => {
211
230
  await writeFile(configPath, setConfigValues(raw, updates));
212
231
  };
213
232
  //#endregion
233
+ //#region src/config/find-predated-keys.ts
234
+ const findPredatedKeys = (rawYaml, fromVersion, toVersion, sinceMap) => {
235
+ const doc = parseDocument(rawYaml);
236
+ 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]) => ({
237
+ path,
238
+ since
239
+ })).toSorted((a, b) => a.path.localeCompare(b.path));
240
+ };
241
+ 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).`;
242
+ //#endregion
214
243
  //#region src/config/pointer.schema.ts
215
244
  const pointerScalar = z.union([
216
245
  z.string(),
@@ -229,6 +258,7 @@ Object.keys(pointerFrontmatterSchema.shape);
229
258
  //#region src/paths/claude-paths.constant.ts
230
259
  const CLAUDE_DIR = ".claude";
231
260
  const STATE_DIR = join(CLAUDE_DIR, "state");
261
+ const TASKS_DIR = join(STATE_DIR, "tasks");
232
262
  const SKILLS_DIR = join(CLAUDE_DIR, "skills");
233
263
  const AGENTS_DIR = join(CLAUDE_DIR, "agents");
234
264
  const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
@@ -542,6 +572,11 @@ const FLAG_LABELS = [
542
572
  name: "harness:needs-design",
543
573
  color: "d4548d",
544
574
  description: "Task touches UI; a design handoff (ui-handoff.md) is owed before spec-ready."
575
+ },
576
+ {
577
+ name: "harness:partition-plan",
578
+ color: "b4a8ff",
579
+ description: "Partition plan of a feature: the approved cut + status of its parts. Never a task itself."
545
580
  }
546
581
  ];
547
582
  const DISCOVERY_LABELS = [
@@ -2037,6 +2072,544 @@ const stampBaseline = (parsed, hash) => {
2037
2072
  };
2038
2073
  const asMessage = (error) => error instanceof Error ? error.message : String(error);
2039
2074
  //#endregion
2075
+ //#region src/review-ledger/review-ledger.constant.ts
2076
+ const REVIEW_LEDGER_DIRNAME = "review-ledger";
2077
+ const FULL_PASS_FILENAME = "full-pass.json";
2078
+ const FULL_PASS_STEP = "full-pass";
2079
+ const CRITERION_ID = /^[RT]\d+$/;
2080
+ const SPEC_SIDE_KINDS = [
2081
+ "spec-missing",
2082
+ "group-missing",
2083
+ "group-empty",
2084
+ "duplicate-group-index",
2085
+ "orphan-task",
2086
+ "malformed-risk-marker",
2087
+ "malformed-task-refs",
2088
+ "unknown-risk-class",
2089
+ "dangling-requirement-ref"
2090
+ ];
2091
+ const GROUP_HEADER = /^##\s+Group\s+(\d+)\b/;
2092
+ const RISK_MARKER = /\[risk:\s*([^\]]*)\]\s*$/;
2093
+ const RISK_MARKER_ALL = /\[risk:/gi;
2094
+ const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2095
+ const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2096
+ const TASK_REFS = /^\(((?:R\d+)(?:\s*,\s*R\d+)*)\)/;
2097
+ const TASK_REFS_LOOKALIKE = /\(\s*R\d+/;
2098
+ const TASK_REFS_AFTER_TITLE = /(?:\*\*|__)\s*(\(\s*R\d+)/;
2099
+ const REQUIREMENT_LINE = /^\s*-\s*\*\*(R\d+)\*\*/;
2100
+ const TEST_FILE = /(^|\/)__tests__\/|\.(spec|test)\.[cm]?[jt]sx?$/;
2101
+ //#endregion
2102
+ //#region src/review-ledger/review-ledger.model.ts
2103
+ const RISK_CLASSES = [
2104
+ "auth",
2105
+ "payments",
2106
+ "shell-process",
2107
+ "data-loss",
2108
+ "secrets",
2109
+ "executable-mode"
2110
+ ];
2111
+ const MUTANT_OUTCOMES = ["killed", "survived"];
2112
+ const NOT_APPLICABLE_REASONS = [
2113
+ "no-mutable-logic",
2114
+ "change-without-logic",
2115
+ "generated",
2116
+ "outside-declared-risk"
2117
+ ];
2118
+ //#endregion
2119
+ //#region src/review-ledger/parse-spec.ts
2120
+ const parseTasksSpec = (text) => {
2121
+ const lines = text.split(/\r?\n/);
2122
+ const grouped = lines.some((line) => GROUP_HEADER.test(line));
2123
+ const groups = [];
2124
+ const orphanTaskIds = [];
2125
+ const malformedRiskHeaders = [];
2126
+ const malformedTaskRefs = [];
2127
+ let current;
2128
+ for (let index = 0; index < lines.length; index += 1) {
2129
+ const line = lines[index] ?? "";
2130
+ const header = GROUP_HEADER.exec(line);
2131
+ if (header) {
2132
+ const risk = parseRiskMarker(line);
2133
+ if (risk.malformed) malformedRiskHeaders.push(line.trim());
2134
+ current = {
2135
+ index: Number(header[1]),
2136
+ header: line.trim(),
2137
+ riskClasses: risk.riskClasses,
2138
+ unknownRiskClasses: risk.unknownRiskClasses,
2139
+ tasks: []
2140
+ };
2141
+ groups.push(current);
2142
+ continue;
2143
+ }
2144
+ const match = TASK_LINE.exec(line);
2145
+ if (!match) continue;
2146
+ const blockEnd = taskBlockEnd(lines, index);
2147
+ const parts = lines.slice(index, blockEnd).map((part) => part.trim());
2148
+ index = blockEnd - 1;
2149
+ const task = parseTaskBlock(match[1] ?? "", parts);
2150
+ if (task.malformedRefs) malformedTaskRefs.push(task.id);
2151
+ if (current) {
2152
+ current.tasks.push(task.task);
2153
+ continue;
2154
+ }
2155
+ if (grouped) {
2156
+ orphanTaskIds.push(task.id);
2157
+ continue;
2158
+ }
2159
+ groups.push({
2160
+ index: groups.length + 1,
2161
+ header: `(ungrouped) ${task.id}`,
2162
+ riskClasses: [],
2163
+ unknownRiskClasses: [],
2164
+ tasks: [task.task]
2165
+ });
2166
+ }
2167
+ const seen = /* @__PURE__ */ new Set();
2168
+ const duplicateIndexes = [];
2169
+ for (const group of groups) {
2170
+ if (seen.has(group.index) && !duplicateIndexes.includes(group.index)) duplicateIndexes.push(group.index);
2171
+ seen.add(group.index);
2172
+ }
2173
+ return {
2174
+ groups,
2175
+ orphanTaskIds,
2176
+ duplicateIndexes,
2177
+ malformedRiskHeaders,
2178
+ malformedTaskRefs
2179
+ };
2180
+ };
2181
+ const taskBlockEnd = (lines, start) => {
2182
+ let end = start + 1;
2183
+ while (end < lines.length) {
2184
+ const line = lines[end] ?? "";
2185
+ if (line.trim() === "" || !/^\s/.test(line) || TASK_LINE.test(line) || GROUP_HEADER.test(line)) break;
2186
+ end += 1;
2187
+ }
2188
+ return end;
2189
+ };
2190
+ const parseTaskBlock = (id, parts) => {
2191
+ const block = parts.join(" ");
2192
+ const opener = refsOpener(parts, block);
2193
+ const refs = opener === void 0 ? null : declarationAt(parts, block, opener);
2194
+ const requirementRefs = refs ? (refs[1] ?? "").split(",").map((ref) => ref.trim()).filter(Boolean) : [];
2195
+ return {
2196
+ id,
2197
+ task: {
2198
+ id,
2199
+ requirementRefs: [...new Set(requirementRefs)]
2200
+ },
2201
+ malformedRefs: refs === null && TASK_REFS_LOOKALIKE.test(block)
2202
+ };
2203
+ };
2204
+ const declarationAt = (parts, block, opener) => {
2205
+ const candidate = TASK_REFS.exec(block.slice(opener));
2206
+ if (!candidate) return null;
2207
+ return declarationClosesLine(parts, opener + candidate[0].length) ? candidate : null;
2208
+ };
2209
+ const declarationClosesLine = (parts, end) => {
2210
+ const lineEnds = /* @__PURE__ */ new Set();
2211
+ let offset = 0;
2212
+ for (const part of parts) {
2213
+ offset += part.length;
2214
+ lineEnds.add(offset);
2215
+ offset += 1;
2216
+ }
2217
+ if (lineEnds.has(end)) return true;
2218
+ const tail = parts.join(" ").slice(end);
2219
+ return /^(?:\*\*|__)/.test(tail) && lineEnds.has(end + 2);
2220
+ };
2221
+ const refsOpener = (parts, block) => {
2222
+ const candidates = [];
2223
+ const onFirstLine = TASK_REFS_LOOKALIKE.exec(parts[0] ?? "")?.index;
2224
+ if (onFirstLine !== void 0) candidates.push(onFirstLine);
2225
+ const afterTitle = TASK_REFS_AFTER_TITLE.exec(block);
2226
+ if (afterTitle) candidates.push(afterTitle.index + afterTitle[0].lastIndexOf("("));
2227
+ let offset = (parts[0] ?? "").length + 1;
2228
+ for (const part of parts.slice(1)) {
2229
+ if (/^\(\s*R\d+/.test(part)) candidates.push(offset);
2230
+ offset += part.length + 1;
2231
+ }
2232
+ return candidates.length === 0 ? void 0 : Math.min(...candidates);
2233
+ };
2234
+ const parseRiskMarker = (line) => {
2235
+ const marker = RISK_MARKER.exec(line);
2236
+ const markerCount = (line.match(RISK_MARKER_ALL) ?? []).length;
2237
+ if (!marker) return {
2238
+ riskClasses: [],
2239
+ unknownRiskClasses: [],
2240
+ malformed: RISK_MARKER_LOOKALIKE.test(line)
2241
+ };
2242
+ const riskClasses = [];
2243
+ const unknownRiskClasses = [];
2244
+ for (const raw of (marker[1] ?? "").split(",")) {
2245
+ const tag = raw.trim();
2246
+ if (tag === "") continue;
2247
+ if (RISK_CLASSES.includes(tag)) {
2248
+ if (!riskClasses.includes(tag)) riskClasses.push(tag);
2249
+ continue;
2250
+ }
2251
+ if (!unknownRiskClasses.includes(tag)) unknownRiskClasses.push(tag);
2252
+ }
2253
+ return {
2254
+ riskClasses,
2255
+ unknownRiskClasses,
2256
+ malformed: riskClasses.length === 0 && unknownRiskClasses.length === 0 || markerCount > 1
2257
+ };
2258
+ };
2259
+ const parseRequirementIds = (text) => {
2260
+ const ids = [];
2261
+ for (const line of text.split(/\r?\n/)) {
2262
+ const id = REQUIREMENT_LINE.exec(line)?.[1];
2263
+ if (id && !ids.includes(id)) ids.push(id);
2264
+ }
2265
+ return ids;
2266
+ };
2267
+ const sliceForGroups = (groups) => {
2268
+ const requirementIds = [];
2269
+ const unreferencedTaskIds = [];
2270
+ for (const group of groups) for (const task of group.tasks) {
2271
+ if (task.requirementRefs.length === 0) {
2272
+ if (!unreferencedTaskIds.includes(task.id)) unreferencedTaskIds.push(task.id);
2273
+ continue;
2274
+ }
2275
+ for (const ref of task.requirementRefs) if (!requirementIds.includes(ref)) requirementIds.push(ref);
2276
+ }
2277
+ const basis = requirementIds.length > 0 && unreferencedTaskIds.length > 0 ? "mixed" : requirementIds.length > 0 ? "requirements" : "tasks";
2278
+ return {
2279
+ ids: [...requirementIds, ...unreferencedTaskIds],
2280
+ basis
2281
+ };
2282
+ };
2283
+ //#endregion
2284
+ //#region src/review-ledger/review-ledger.schema.ts
2285
+ const prose = z.string().trim().min(1);
2286
+ const criterionId = z.string().regex(CRITERION_ID);
2287
+ const criterionSchema = z.object({
2288
+ id: criterionId,
2289
+ evidence: prose
2290
+ }).strict();
2291
+ const gateSchema = z.discriminatedUnion("kind", [z.object({
2292
+ kind: z.literal("script"),
2293
+ script: prose,
2294
+ evidence: prose
2295
+ }).strict(), z.object({
2296
+ kind: z.literal("real-run"),
2297
+ evidence: prose
2298
+ }).strict()]);
2299
+ const probeSchema = z.object({
2300
+ mutation: prose,
2301
+ outcome: z.enum(MUTANT_OUTCOMES),
2302
+ killedBy: prose.optional()
2303
+ }).strict();
2304
+ const mutantFileSchema = z.discriminatedUnion("status", [z.object({
2305
+ file: prose,
2306
+ status: z.literal("probed"),
2307
+ probes: z.array(probeSchema).min(1)
2308
+ }).strict(), z.object({
2309
+ file: prose,
2310
+ status: z.literal("not-applicable"),
2311
+ reason: z.enum(NOT_APPLICABLE_REASONS),
2312
+ note: prose
2313
+ }).strict()]);
2314
+ const mutantsSchema = z.discriminatedUnion("basis", [z.object({ basis: z.literal("no-declared-risk") }).strict(), z.object({
2315
+ basis: z.literal("declared-risk"),
2316
+ files: z.array(mutantFileSchema)
2317
+ }).strict()]);
2318
+ const ledgerStepSchema = z.union([z.int().min(1), z.literal(FULL_PASS_STEP)]);
2319
+ const reviewLedgerSchema = z.object({
2320
+ version: z.literal(1),
2321
+ step: ledgerStepSchema,
2322
+ criteria: z.array(criterionSchema),
2323
+ gates: z.array(gateSchema),
2324
+ mutants: mutantsSchema
2325
+ }).strict();
2326
+ //#endregion
2327
+ //#region src/review-ledger/validate-ledger.ts
2328
+ const runLedgerValidate = async (inputs) => {
2329
+ const { address } = inputs;
2330
+ const problems = [];
2331
+ const taskRel = join(TASKS_DIR, inputs.taskId);
2332
+ const tasksRel = join(taskRel, "spec", "tasks.md");
2333
+ const requirementsPath = join(inputs.repoRoot, taskRel, "spec", "requirements.md");
2334
+ const ledgerPath = reviewLedgerPath(inputs.taskId, address);
2335
+ const owedFiles = await collectOwedFiles(inputs);
2336
+ const declaredGates = await collectDeclaredGates(inputs.repoRoot);
2337
+ const result = {
2338
+ ok: false,
2339
+ address,
2340
+ ledgerPath,
2341
+ criteria: {
2342
+ required: [],
2343
+ present: []
2344
+ },
2345
+ gates: owedGates(declaredGates),
2346
+ unknownRiskClasses: [],
2347
+ problems
2348
+ };
2349
+ if (!await pathExists(join(inputs.repoRoot, tasksRel))) {
2350
+ problems.push({
2351
+ kind: "spec-missing",
2352
+ message: `No ${tasksRel} — the criteria class is enumerated from the spec's groups, so there is nothing to validate against.`
2353
+ });
2354
+ return result;
2355
+ }
2356
+ const spec = parseTasksSpec(await readFile(join(inputs.repoRoot, tasksRel), "utf8"));
2357
+ for (const index of spec.duplicateIndexes) problems.push({
2358
+ kind: "duplicate-group-index",
2359
+ 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.`,
2360
+ subject: String(index)
2361
+ });
2362
+ if (spec.orphanTaskIds.length > 0) problems.push({
2363
+ kind: "orphan-task",
2364
+ message: `${spec.orphanTaskIds.join(", ")} sit above the first group header, so they belong to no step and no review covers them.`
2365
+ });
2366
+ for (const header of spec.malformedRiskHeaders) problems.push({
2367
+ kind: "malformed-risk-marker",
2368
+ 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.`,
2369
+ subject: header
2370
+ });
2371
+ for (const id of spec.malformedTaskRefs) problems.push({
2372
+ kind: "malformed-task-refs",
2373
+ 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.`,
2374
+ subject: id
2375
+ });
2376
+ const groups = resolveGroups(spec.groups, address, problems);
2377
+ if (groups === void 0) return result;
2378
+ if (address.kind === "step") result.groupHeader = groups[0]?.header;
2379
+ for (const group of groups) {
2380
+ if (group.tasks.length === 0) problems.push({
2381
+ kind: "group-empty",
2382
+ message: `Group ${group.index} carries no tasks — an empty group is a broken spec, not a trivial step.`,
2383
+ subject: group.header
2384
+ });
2385
+ for (const unknown of group.unknownRiskClasses) {
2386
+ if (!result.unknownRiskClasses.includes(unknown)) result.unknownRiskClasses.push(unknown);
2387
+ problems.push({
2388
+ kind: "unknown-risk-class",
2389
+ 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.`,
2390
+ subject: unknown
2391
+ });
2392
+ }
2393
+ }
2394
+ const slice = sliceForGroups(groups);
2395
+ result.basis = slice.basis;
2396
+ const dangling = await collectDanglingRefs(requirementsPath, slice.ids, problems);
2397
+ const required = slice.ids.filter((id) => !dangling.includes(id));
2398
+ result.criteria = {
2399
+ required,
2400
+ present: []
2401
+ };
2402
+ const ledger = await readLedger(join(inputs.repoRoot, ledgerPath), ledgerPath, problems);
2403
+ if (ledger === void 0) return result;
2404
+ checkAddress(ledger, address, ledgerPath, problems);
2405
+ result.criteria = checkCriteria(required, dangling, ledger, problems);
2406
+ result.gates = checkGates(ledger, result.gates, problems);
2407
+ checkMutantsBasis(ledger, groups, ledgerPath, problems);
2408
+ checkMutantsFloor(ledger, owedFiles, inputs.anchor, problems);
2409
+ result.ok = problems.length === 0;
2410
+ return result;
2411
+ };
2412
+ const reviewLedgerPath = (taskId, address) => join(TASKS_DIR, taskId, REVIEW_LEDGER_DIRNAME, address.kind === "full-pass" ? FULL_PASS_FILENAME : `step-${address.step}.json`);
2413
+ const resolveGroups = (groups, address, problems) => {
2414
+ if (address.kind === "full-pass") {
2415
+ if (groups.length > 0) return groups;
2416
+ problems.push({
2417
+ kind: "group-missing",
2418
+ message: `tasks.md declares no group and no task, so the full pass has no slice to validate against.`
2419
+ });
2420
+ return;
2421
+ }
2422
+ const group = groups.find((candidate) => candidate.index === address.step);
2423
+ if (group) return [group];
2424
+ problems.push({
2425
+ kind: "group-missing",
2426
+ message: `tasks.md declares ${groups.length} group(s); step ${address.step} has none. One step is one group.`
2427
+ });
2428
+ };
2429
+ const collectDanglingRefs = async (requirementsPath, sliceIds, problems) => {
2430
+ const referenced = sliceIds.filter((id) => id.startsWith("R"));
2431
+ if (referenced.length === 0) return [];
2432
+ if (!await pathExists(requirementsPath)) {
2433
+ problems.push({
2434
+ kind: "dangling-requirement-ref",
2435
+ message: `The slice references ${referenced.join(", ")} but there is no requirements.md to resolve them against.`
2436
+ });
2437
+ return referenced;
2438
+ }
2439
+ const declared = parseRequirementIds(await readFile(requirementsPath, "utf8"));
2440
+ const dangling = referenced.filter((id) => !declared.includes(id));
2441
+ for (const id of dangling) problems.push({
2442
+ kind: "dangling-requirement-ref",
2443
+ message: `The slice references ${id}, which requirements.md does not declare.`,
2444
+ subject: id
2445
+ });
2446
+ return dangling;
2447
+ };
2448
+ const readLedger = async (absolutePath, ledgerPath, problems) => {
2449
+ if (!await pathExists(absolutePath)) {
2450
+ problems.push({
2451
+ kind: "ledger-missing",
2452
+ message: `No ${ledgerPath} — the Reviewer writes it beside its verdict; an APPROVE without a ledger is structurally invalid.`
2453
+ });
2454
+ return;
2455
+ }
2456
+ let parsed;
2457
+ try {
2458
+ parsed = JSON.parse(await readFile(absolutePath, "utf8"));
2459
+ } catch (error) {
2460
+ problems.push({
2461
+ kind: "ledger-unparseable",
2462
+ message: `${ledgerPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}.`
2463
+ });
2464
+ return;
2465
+ }
2466
+ const checked = reviewLedgerSchema.safeParse(parsed);
2467
+ if (checked.success) return checked.data;
2468
+ for (const issue of checked.error.issues) {
2469
+ const at = formatPath(issue.path);
2470
+ problems.push({
2471
+ kind: "ledger-shape",
2472
+ message: `${ledgerPath} does not match the schema at ${at || "<root>"}: ${issue.message}.`,
2473
+ subject: at || void 0
2474
+ });
2475
+ }
2476
+ };
2477
+ const formatPath = (path) => path.map((segment) => typeof segment === "number" ? `[${segment}]` : `.${String(segment)}`).join("").replace(/^\./, "");
2478
+ const checkAddress = (ledger, address, ledgerPath, problems) => {
2479
+ const expected = address.kind === "full-pass" ? FULL_PASS_STEP : address.step;
2480
+ if (ledger.step === expected) return;
2481
+ problems.push({
2482
+ kind: "ledger-address-mismatch",
2483
+ message: `${ledgerPath} declares "step": ${JSON.stringify(ledger.step)} but was addressed as ${JSON.stringify(expected)}.`,
2484
+ subject: String(ledger.step)
2485
+ });
2486
+ };
2487
+ const checkMutantsBasis = (ledger, groups, ledgerPath, problems) => {
2488
+ const declared = groups.filter((group) => group.riskClasses.length > 0);
2489
+ if (declared.length === 0 || ledger.mutants.basis === "declared-risk") return;
2490
+ problems.push({
2491
+ kind: "mutants-basis-mismatch",
2492
+ 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.`,
2493
+ subject: ledger.mutants.basis
2494
+ });
2495
+ };
2496
+ const gitFailureDetail = (result, verb) => result.stderr.trim().split("\n")[0] || `git ${verb} exited ${result.code}`;
2497
+ const collectOwedFiles = async (inputs) => {
2498
+ const resolved = await inputs.runCommand("git", [
2499
+ "-C",
2500
+ inputs.repoRoot,
2501
+ "rev-parse",
2502
+ "--verify",
2503
+ "--end-of-options",
2504
+ `${inputs.anchor}^{commit}`
2505
+ ]);
2506
+ if (resolved.code !== 0) throw new Error(`Invalid --anchor="${inputs.anchor}": ${gitFailureDetail(resolved, "rev-parse")}`);
2507
+ const oid = resolved.stdout.trim();
2508
+ const listChanged = async (cached) => {
2509
+ const diff = await inputs.runCommand("git", [
2510
+ "-C",
2511
+ inputs.repoRoot,
2512
+ "diff",
2513
+ ...cached ? ["--cached"] : [],
2514
+ "--name-only",
2515
+ "-z",
2516
+ "--diff-filter=d",
2517
+ oid,
2518
+ "--",
2519
+ ":(exclude).claude/state"
2520
+ ]);
2521
+ if (diff.code !== 0) throw new Error(`git diff against --anchor="${inputs.anchor}" failed: ${gitFailureDetail(diff, "diff")}`);
2522
+ return diff.stdout.split("\0").filter((file) => file.length > 0);
2523
+ };
2524
+ const worktree = await listChanged(false);
2525
+ const staged = await listChanged(true);
2526
+ return [.../* @__PURE__ */ new Set([...worktree, ...staged])].filter((file) => !TEST_FILE.test(file));
2527
+ };
2528
+ const checkMutantsFloor = (ledger, owedFiles, anchor, problems) => {
2529
+ if (ledger.mutants.basis !== "declared-risk") return;
2530
+ const recorded = new Set(ledger.mutants.files.map((entry) => entry.file));
2531
+ for (const file of owedFiles) {
2532
+ if (recorded.has(file)) continue;
2533
+ problems.push({
2534
+ kind: "unaccounted-file",
2535
+ 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.`,
2536
+ subject: file
2537
+ });
2538
+ }
2539
+ };
2540
+ const collectDeclaredGates = async (repoRoot) => {
2541
+ if (!await configEntryExists(join(repoRoot, "harness.config.yml"))) return;
2542
+ return (await readHarnessConfig(repoRoot)).gates;
2543
+ };
2544
+ const configEntryExists = async (path) => {
2545
+ try {
2546
+ await lstat(path);
2547
+ return true;
2548
+ } catch (error) {
2549
+ if (error.code === "ENOENT") return false;
2550
+ throw error;
2551
+ }
2552
+ };
2553
+ const owedGates = (declared) => ({
2554
+ basis: declared === void 0 ? "undeclared" : "config",
2555
+ required: [...new Set(declared ?? [])],
2556
+ present: []
2557
+ });
2558
+ const checkGates = (ledger, owed, problems) => {
2559
+ const attested = new Set(ledger.gates.filter((gate) => gate.kind === "script").map((gate) => gate.script));
2560
+ for (const name of owed.required) {
2561
+ if (attested.has(name)) continue;
2562
+ problems.push({
2563
+ kind: "gate-unattested",
2564
+ 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).`,
2565
+ subject: name
2566
+ });
2567
+ }
2568
+ if (!ledger.gates.some((gate) => gate.kind === "real-run")) problems.push({
2569
+ kind: "real-run-missing",
2570
+ message: `The ledger has no {"kind": "real-run"} entry — the real run is the floor every review owes, declared gates or none.`
2571
+ });
2572
+ return {
2573
+ ...owed,
2574
+ present: owed.required.filter((name) => attested.has(name))
2575
+ };
2576
+ };
2577
+ const checkCriteria = (required, dangling, ledger, problems) => {
2578
+ const present = [];
2579
+ for (const entry of ledger.criteria) {
2580
+ if (dangling.includes(entry.id)) continue;
2581
+ if (!required.includes(entry.id)) {
2582
+ problems.push({
2583
+ kind: "criteria-unexpected",
2584
+ message: `"${entry.id}" is not in the slice (${required.join(", ") || "empty"}). An entry for something the review does not cover is not evidence.`,
2585
+ subject: entry.id
2586
+ });
2587
+ continue;
2588
+ }
2589
+ if (present.includes(entry.id)) {
2590
+ problems.push({
2591
+ kind: "criteria-duplicated",
2592
+ message: `${entry.id} has more than one entry; one criterion, one entry.`,
2593
+ subject: entry.id
2594
+ });
2595
+ continue;
2596
+ }
2597
+ present.push(entry.id);
2598
+ }
2599
+ for (const id of required) {
2600
+ if (present.includes(id)) continue;
2601
+ problems.push({
2602
+ kind: "criteria-missing",
2603
+ message: `${id} is in the slice and has no criteria entry.`,
2604
+ subject: id
2605
+ });
2606
+ }
2607
+ return {
2608
+ required,
2609
+ present
2610
+ };
2611
+ };
2612
+ //#endregion
2040
2613
  //#region src/spinoff/spinoff.constant.ts
2041
2614
  const MANAGED_LABEL = "harness:managed";
2042
2615
  const PENDING_STATUS_LABEL = "harness:status:pending";
@@ -2810,7 +3383,6 @@ const inspectDevDependency = async (repoRoot) => {
2810
3383
  //#endregion
2811
3384
  //#region src/scan/scan.constant.ts
2812
3385
  const ARCHITECTURE_DOC_PATH = "docs/architecture.md";
2813
- const MUTATION_SCRIPT_NAME = "test:mutation";
2814
3386
  //#endregion
2815
3387
  //#region src/scan/scan.ts
2816
3388
  const ORIGIN_URL = /\[remote "origin"\][^[]*?url\s*=\s*(\S+)/;
@@ -2831,23 +3403,14 @@ const readOriginSlug = async (root) => {
2831
3403
  return null;
2832
3404
  }
2833
3405
  };
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
3406
  const scanRepo = async (root) => {
2843
- const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc, hasMutationTesting] = await Promise.all([
3407
+ const [isGitRepo, hasClaudeMd, hasContextMd, hasDocs, hasPackageJson, hasArchitectureDoc] = await Promise.all([
2844
3408
  pathExists(join(root, ".git")),
2845
3409
  pathExists(join(root, "CLAUDE.md")),
2846
3410
  pathExists(join(root, "CONTEXT.md")),
2847
3411
  pathExists(join(root, "docs")),
2848
3412
  pathExists(join(root, "package.json")),
2849
- pathExists(join(root, ARCHITECTURE_DOC_PATH)),
2850
- hasMutationScript(root)
3413
+ pathExists(join(root, ARCHITECTURE_DOC_PATH))
2851
3414
  ]);
2852
3415
  return {
2853
3416
  isGitRepo,
@@ -2856,8 +3419,7 @@ const scanRepo = async (root) => {
2856
3419
  hasContextMd,
2857
3420
  hasDocs,
2858
3421
  hasPackageJson,
2859
- hasArchitectureDoc,
2860
- hasMutationTesting
3422
+ hasArchitectureDoc
2861
3423
  };
2862
3424
  };
2863
3425
  //#endregion
@@ -2899,18 +3461,11 @@ const PHASES = [
2899
3461
  ];
2900
3462
  //#endregion
2901
3463
  //#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
- };
3464
+ const CAPABILITY_REGISTRY = { "has-architecture-doc": {
3465
+ predicate: (caps) => caps.hasArchitectureDoc,
3466
+ trigger: ARCHITECTURE_DOC_PATH,
3467
+ label: "keep your architecture map current"
3468
+ } };
2914
3469
  const capabilityHolds = (key, caps) => {
2915
3470
  const entry = CAPABILITY_REGISTRY[key];
2916
3471
  if (!entry) throw new Error(`unknown applies-when capability key "${key}"`);
@@ -3033,6 +3588,7 @@ const runDoctor = async (deps) => {
3033
3588
  checks.push(await checkCliResolution(deps));
3034
3589
  checks.push(await checkCapabilities(deps, config));
3035
3590
  checks.push(await checkDesignToolDrift(deps));
3591
+ checks.push(await checkHookLib(deps));
3036
3592
  return {
3037
3593
  checks,
3038
3594
  ok: checks.every((check) => check.status !== "fail")
@@ -3301,6 +3857,91 @@ const checkDesignToolDrift = async (deps) => {
3301
3857
  };
3302
3858
  }
3303
3859
  };
3860
+ const checkHookLib = async (deps) => {
3861
+ const name = "hook-lib";
3862
+ let helpers;
3863
+ try {
3864
+ helpers = (await listFiles(join(deps.vendorRoot, "hooks", "lib"))).filter((rel) => rel.endsWith(".sh")).toSorted();
3865
+ } catch (error) {
3866
+ return {
3867
+ name,
3868
+ status: "warn",
3869
+ detail: `Could not read the vendor catalog's hook lib helpers: ${error.message}`,
3870
+ remediation: "Reinstall the harness package, then re-run `lemony doctor`."
3871
+ };
3872
+ }
3873
+ if (helpers.length === 0) return {
3874
+ name,
3875
+ status: "warn",
3876
+ detail: "The vendor catalog ships no hook lib helpers — the installed package looks damaged.",
3877
+ remediation: "Reinstall the harness package, then re-run `lemony doctor`."
3878
+ };
3879
+ const problems = (await Promise.all(helpers.map(async (helper) => {
3880
+ const installed = join(deps.repoRoot, HOOKS_DIR, "lib", helper);
3881
+ let installedStat;
3882
+ try {
3883
+ installedStat = await stat(installed);
3884
+ } catch (error) {
3885
+ const code = error.code;
3886
+ if (await lstat(installed).then(() => true, () => false)) return {
3887
+ helper,
3888
+ kind: "obstructed",
3889
+ description: `${helper} is an unresolvable symlink (${code ?? "unknown error"})`
3890
+ };
3891
+ if (code === "ENOENT" || code === "ENOTDIR") return {
3892
+ helper,
3893
+ kind: "missing",
3894
+ description: `${helper} is missing`
3895
+ };
3896
+ return {
3897
+ helper,
3898
+ kind: "unreadable",
3899
+ description: `${helper} is unreadable (${code ?? "unknown error"})`
3900
+ };
3901
+ }
3902
+ if (!installedStat.isFile()) return {
3903
+ helper,
3904
+ kind: "obstructed",
3905
+ description: `${helper} is not a regular file`
3906
+ };
3907
+ if (!await isExecutable(installed)) return {
3908
+ helper,
3909
+ kind: "mode",
3910
+ description: `${helper} is not executable`
3911
+ };
3912
+ if (!await isReadable(installed)) return {
3913
+ helper,
3914
+ kind: "mode",
3915
+ description: `${helper} is not readable`
3916
+ };
3917
+ return null;
3918
+ }))).filter((problem) => problem !== null);
3919
+ if (problems.length === 0) return {
3920
+ name,
3921
+ status: "ok",
3922
+ detail: `All ${helpers.length} hook lib helpers installed with read+exec permission in ${HOOKS_DIR}/lib.`
3923
+ };
3924
+ const pathsOf = (kind) => problems.filter((problem) => problem.kind === kind).map((problem) => `${HOOKS_DIR}/lib/${problem.helper}`).join(" ");
3925
+ const remediations = [];
3926
+ if (problems.some((problem) => problem.kind === "missing")) remediations.push("Run `lemony repair` to restore the missing helpers");
3927
+ if (problems.some((problem) => problem.kind === "obstructed")) remediations.push(`remove ${pathsOf("obstructed")} (\`repair\` cannot fix a non-file entry), then run \`lemony repair\``);
3928
+ 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)`);
3929
+ 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\``);
3930
+ return {
3931
+ name,
3932
+ status: "warn",
3933
+ detail: `${HOOKS_DIR}/lib: ${problems.map((problem) => problem.description).join("; ")} — agent-executed merge paths (merge gate, closeout, hotfix) fail at runtime.`,
3934
+ remediation: `${remediations.join("; ")}.`
3935
+ };
3936
+ };
3937
+ const isReadable = async (path) => {
3938
+ try {
3939
+ await access(path, constants.R_OK);
3940
+ return true;
3941
+ } catch {
3942
+ return false;
3943
+ }
3944
+ };
3304
3945
  //#endregion
3305
3946
  //#region src/status/status.ts
3306
3947
  const PAUSED_LABEL = "harness:status:paused-for-clarification";
@@ -3421,7 +4062,7 @@ const COMMANDS = [
3421
4062
  },
3422
4063
  {
3423
4064
  name: "discovery",
3424
- summary: "Reflect a raised/resolved discovery onto its issue (label flip + comment); used by the Orchestrator's resolve-discovery skill.",
4065
+ summary: "Reflect a raised/resolved discovery onto its issue (label flip; `pause` also comments); used by the Orchestrator's resolve-discovery skill.",
3425
4066
  usage: "lemony discovery <pause|resume> --task-id=<id> --tier=<T1..T6> --status=<spec-in-progress|in-progress|in-review> [--note=<text>]"
3426
4067
  },
3427
4068
  {
@@ -3429,6 +4070,11 @@ const COMMANDS = [
3429
4070
  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
4071
  usage: "lemony design-tokens <validate [--scan=<dir>] | contrast | import --from=<file> [--apply] [--only=<paths>] | export [--tool-state=<file>] [--out=<file>] [--record]>"
3431
4072
  },
4073
+ {
4074
+ name: "review-ledger",
4075
+ 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).",
4076
+ usage: "lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass)"
4077
+ },
3432
4078
  {
3433
4079
  name: "spinoff",
3434
4080
  summary: "Capture a non-blocking defect found mid-task as a pending stub (+ followup_captured event); used by the /spinoff command.",
@@ -3437,7 +4083,7 @@ const COMMANDS = [
3437
4083
  {
3438
4084
  name: "telemetry",
3439
4085
  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]"
4086
+ usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
3441
4087
  }
3442
4088
  ];
3443
4089
  //#endregion
@@ -3930,6 +4576,13 @@ const CORE_AGENTS = [
3930
4576
  "architect",
3931
4577
  "ui-designer"
3932
4578
  ];
4579
+ const COMPANION_DOCS = [
4580
+ "fit-assessment",
4581
+ "triage",
4582
+ "spinoff",
4583
+ "ui-design",
4584
+ "partition"
4585
+ ];
3933
4586
  const renderFile = async (templatePath, relPath, vars) => {
3934
4587
  return {
3935
4588
  relPath,
@@ -3955,7 +4608,7 @@ const materializeVendorFiles = async (ctx) => {
3955
4608
  SKILLS: renderRoleSkills(skills, role),
3956
4609
  vendor_version: vendorVersion
3957
4610
  }), vendorVersion)));
3958
- const fitAssessment = await copyFileEntry(join(vendorRoot, "agents", "fit-assessment.md"), join(AGENTS_DIR, "fit-assessment.md"));
4611
+ const companionFiles = await Promise.all(COMPANION_DOCS.map((doc) => copyFileEntry(join(vendorRoot, "agents", `${doc}.md`), join(AGENTS_DIR, `${doc}.md`))));
3959
4612
  const skillFiles = (await Promise.all(skills.map(async (skill) => {
3960
4613
  const skillDir = join(vendorRoot, "skills", skill.name);
3961
4614
  const rels = await listFiles(skillDir);
@@ -3972,7 +4625,7 @@ const materializeVendorFiles = async (ctx) => {
3972
4625
  return [
3973
4626
  entryProtocol,
3974
4627
  ...agentFiles,
3975
- fitAssessment,
4628
+ ...companionFiles,
3976
4629
  ...skillFiles,
3977
4630
  playbooksReadme,
3978
4631
  ...hookFiles,
@@ -4268,6 +4921,7 @@ const runReconcile = async (inputs) => {
4268
4921
  const fromVersion = config.vendor_version;
4269
4922
  const target = config.target;
4270
4923
  const taskStorageRepo = config.task_storage.repo;
4924
+ const predatedKeys = findPredatedKeys(await readFile(join(repoRoot, HARNESS_CONFIG_FILENAME), "utf8"), fromVersion, toVersion, inputs.configKeySince ?? CONFIG_KEY_SINCE);
4271
4925
  const baselineVersion = await findBaselineVersion(repoRoot);
4272
4926
  const baseline = baselineVersion ? await readBaseline(repoRoot, baselineVersion) : /* @__PURE__ */ new Map();
4273
4927
  const hadBaseline = baseline.size > 0;
@@ -4304,7 +4958,8 @@ const runReconcile = async (inputs) => {
4304
4958
  hadBaseline,
4305
4959
  ...summarize(actions),
4306
4960
  labelSync: null,
4307
- lostHookCommands: []
4961
+ lostHookCommands: [],
4962
+ predatedKeys
4308
4963
  };
4309
4964
  if (hadBaseline) {
4310
4965
  await writeSnapshot(repoRoot, fromVersion, {
@@ -4322,7 +4977,8 @@ const runReconcile = async (inputs) => {
4322
4977
  hadBaseline,
4323
4978
  ...summarize(actions),
4324
4979
  labelSync,
4325
- lostHookCommands
4980
+ lostHookCommands,
4981
+ predatedKeys
4326
4982
  };
4327
4983
  };
4328
4984
  const refuseOnResidualMarkers = async (relPaths, readClient) => {
@@ -4848,6 +5504,7 @@ const update = async (args) => {
4848
5504
  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}`;
4849
5505
  console.log(`${versionLine}${result.hadBaseline ? "" : " (no baseline — pick-one degrade)"}${suffix}.`);
4850
5506
  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}.`);
5507
+ for (const key of result.predatedKeys) console.log(formatPredatedKeyLine(key));
4851
5508
  for (const { relPath, winner } of result.pickedFiles) console.log(` ${verb("picked", "would pick")} ${winner} for ${relPath} (no baseline).`);
4852
5509
  reportAdoptions(result.adoptedFiles, verb("adopted", "would adopt"));
4853
5510
  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.");
@@ -4997,7 +5654,7 @@ const runTelemetry = async (args) => {
4997
5654
  case "send":
4998
5655
  await telemetrySend(repoRoot);
4999
5656
  return;
5000
- default: throw new Error(`Usage: lemony telemetry <status|show|flush|disable|enable>. Unknown action "${action}".`);
5657
+ default: throw new Error(`Usage: lemony telemetry <status|show|flush|enable|disable [--purge-local]>. Unknown action "${action}".`);
5001
5658
  }
5002
5659
  };
5003
5660
  const telemetryStatus = async (repoRoot) => {
@@ -5172,6 +5829,53 @@ const designTokensExport = async (args) => {
5172
5829
  console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
5173
5830
  if (out !== void 0) console.log(` projection written to ${out}`);
5174
5831
  };
5832
+ const REVIEW_LEDGER_USAGE = "Usage: lemony review-ledger validate --task-id=<id> --anchor=<oid> (--step=<N> | --full-pass).";
5833
+ const reviewLedger = async (args) => {
5834
+ const action = args[0] ?? "validate";
5835
+ if (action === "validate") return reviewLedgerValidate(args);
5836
+ throw new Error(`${REVIEW_LEDGER_USAGE} Unknown action "${action}".`);
5837
+ };
5838
+ const reviewLedgerValidate = async (args) => {
5839
+ const taskId = parseFlag(args, "task-id");
5840
+ if (taskId === void 0) throw new Error(`${REVIEW_LEDGER_USAGE} --task-id is required.`);
5841
+ 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.`);
5842
+ const anchor = parseFlag(args, "anchor");
5843
+ 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 (pre-commit review ON's pre-gate pass anchors at the recorded group anchor instead); the owed set of the mutant floor is the diff from it.`);
5844
+ const address = parseLedgerAddress(args);
5845
+ const result = await runLedgerValidate({
5846
+ repoRoot: cwd(),
5847
+ taskId,
5848
+ address,
5849
+ anchor,
5850
+ runCommand: makeRunCommand()
5851
+ });
5852
+ const where = address.kind === "full-pass" ? "full pass" : `step ${address.step}`;
5853
+ 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})`;
5854
+ if (address.kind === "step" && result.groupHeader !== void 0) console.log(`Step ${address.step} → ${result.groupHeader}`);
5855
+ if (result.ok) {
5856
+ console.log(`Ledger valid for ${where} (${result.ledgerPath}). ${counts}`);
5857
+ return;
5858
+ }
5859
+ console.error(`Ledger invalid for ${where} (${result.ledgerPath}): ${result.problems.length} problem(s). ${counts}`);
5860
+ for (const problem of result.problems) console.error(` [${problem.kind}] ${problem.message}`);
5861
+ const specSide = result.problems.filter((problem) => SPEC_SIDE_KINDS.includes(problem.kind)).length;
5862
+ 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.`);
5863
+ exit(1);
5864
+ };
5865
+ const parseLedgerAddress = (args) => {
5866
+ const step = parseFlag(args, "step");
5867
+ const fullPass = args.includes("--full-pass");
5868
+ if (args.some((arg) => arg.startsWith("--full-pass="))) throw new Error(`${REVIEW_LEDGER_USAGE} --full-pass takes no value.`);
5869
+ if (fullPass && step !== void 0) throw new Error(`${REVIEW_LEDGER_USAGE} --step and --full-pass are mutually exclusive.`);
5870
+ if (fullPass) return { kind: "full-pass" };
5871
+ if (step === void 0) throw new Error(`${REVIEW_LEDGER_USAGE} One of --step or --full-pass is required.`);
5872
+ const stepNumber = Number(step);
5873
+ 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.`);
5874
+ return {
5875
+ kind: "step",
5876
+ step: stepNumber
5877
+ };
5878
+ };
5175
5879
  const spinoff = async (args) => {
5176
5880
  const harnessVersion = await readHarnessVersion();
5177
5881
  const result = await runSpinoff({
@@ -5301,6 +6005,9 @@ const main = async () => {
5301
6005
  case "design-tokens":
5302
6006
  await designTokens(args);
5303
6007
  return;
6008
+ case "review-ledger":
6009
+ await reviewLedger(args);
6010
+ return;
5304
6011
  case "spinoff":
5305
6012
  await spinoff(args);
5306
6013
  return;