@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/README.md +19 -14
- package/catalog/VERSION +1 -1
- package/catalog/agents/architect.md +3 -1
- package/catalog/agents/implementer.md +43 -5
- package/catalog/agents/orchestrator.md +527 -60
- package/catalog/agents/partition.md +316 -0
- package/catalog/agents/reviewer.md +279 -67
- package/catalog/agents/spec-author.md +12 -3
- package/catalog/agents/triage.md +8 -5
- package/catalog/commands/add-capability.md +4 -4
- package/catalog/commands/define.md +7 -0
- package/catalog/commands/hotfix.md +15 -1
- package/catalog/commands/pause.md +5 -0
- package/catalog/commands/resume.md +38 -9
- package/catalog/commands/triage.md +2 -1
- package/catalog/harness.config.schema.json +40 -0
- package/catalog/hooks/init.sh +10 -3
- package/catalog/hooks/lib/merge-pr.sh +699 -0
- package/catalog/skills/mutation-testing/SKILL.md +78 -21
- package/catalog/skills/prd-to-spec/SKILL.md +48 -2
- package/catalog/skills/raise-discovery/SKILL.md +6 -0
- package/catalog/skills/resolve-discovery/SKILL.md +6 -5
- package/catalog/skills/security-review/SKILL.md +119 -6
- package/catalog/skills/spec-to-issue/SKILL.md +7 -1
- package/catalog/skills/task-closeout/SKILL.md +82 -18
- package/catalog/skills/triage-issue/SKILL.md +65 -4
- package/catalog/templates/claude-code/agents.md.tpl +42 -12
- package/catalog/templates/claude-code/harness.config.yml.tpl +37 -0
- package/dist/cli.mjs +748 -36
- 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";
|
|
@@ -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))
|
|
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
|
|
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
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|