@lemoncode/lemony 0.2.0 → 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.
- 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 +519 -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 +37 -9
- package/catalog/commands/triage.md +2 -1
- package/catalog/harness.config.schema.json +40 -0
- 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 +41 -12
- package/catalog/templates/claude-code/harness.config.yml.tpl +33 -0
- package/dist/cli.mjs +737 -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";
|
|
@@ -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))
|
|
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
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
|
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
|
|
4086
|
+
usage: "lemony telemetry <status|show|flush|enable|disable [--purge-local]>"
|
|
3441
4087
|
}
|
|
3442
4088
|
];
|
|
3443
4089
|
//#endregion
|
|
@@ -3934,7 +4580,8 @@ const COMPANION_DOCS = [
|
|
|
3934
4580
|
"fit-assessment",
|
|
3935
4581
|
"triage",
|
|
3936
4582
|
"spinoff",
|
|
3937
|
-
"ui-design"
|
|
4583
|
+
"ui-design",
|
|
4584
|
+
"partition"
|
|
3938
4585
|
];
|
|
3939
4586
|
const renderFile = async (templatePath, relPath, vars) => {
|
|
3940
4587
|
return {
|
|
@@ -4274,6 +4921,7 @@ const runReconcile = async (inputs) => {
|
|
|
4274
4921
|
const fromVersion = config.vendor_version;
|
|
4275
4922
|
const target = config.target;
|
|
4276
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);
|
|
4277
4925
|
const baselineVersion = await findBaselineVersion(repoRoot);
|
|
4278
4926
|
const baseline = baselineVersion ? await readBaseline(repoRoot, baselineVersion) : /* @__PURE__ */ new Map();
|
|
4279
4927
|
const hadBaseline = baseline.size > 0;
|
|
@@ -4310,7 +4958,8 @@ const runReconcile = async (inputs) => {
|
|
|
4310
4958
|
hadBaseline,
|
|
4311
4959
|
...summarize(actions),
|
|
4312
4960
|
labelSync: null,
|
|
4313
|
-
lostHookCommands: []
|
|
4961
|
+
lostHookCommands: [],
|
|
4962
|
+
predatedKeys
|
|
4314
4963
|
};
|
|
4315
4964
|
if (hadBaseline) {
|
|
4316
4965
|
await writeSnapshot(repoRoot, fromVersion, {
|
|
@@ -4328,7 +4977,8 @@ const runReconcile = async (inputs) => {
|
|
|
4328
4977
|
hadBaseline,
|
|
4329
4978
|
...summarize(actions),
|
|
4330
4979
|
labelSync,
|
|
4331
|
-
lostHookCommands
|
|
4980
|
+
lostHookCommands,
|
|
4981
|
+
predatedKeys
|
|
4332
4982
|
};
|
|
4333
4983
|
};
|
|
4334
4984
|
const refuseOnResidualMarkers = async (relPaths, readClient) => {
|
|
@@ -4854,6 +5504,7 @@ const update = async (args) => {
|
|
|
4854
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}`;
|
|
4855
5505
|
console.log(`${versionLine}${result.hadBaseline ? "" : " (no baseline — pick-one degrade)"}${suffix}.`);
|
|
4856
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));
|
|
4857
5508
|
for (const { relPath, winner } of result.pickedFiles) console.log(` ${verb("picked", "would pick")} ${winner} for ${relPath} (no baseline).`);
|
|
4858
5509
|
reportAdoptions(result.adoptedFiles, verb("adopted", "would adopt"));
|
|
4859
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.");
|
|
@@ -5003,7 +5654,7 @@ const runTelemetry = async (args) => {
|
|
|
5003
5654
|
case "send":
|
|
5004
5655
|
await telemetrySend(repoRoot);
|
|
5005
5656
|
return;
|
|
5006
|
-
default: throw new Error(`Usage: lemony telemetry <status|show|flush|disable
|
|
5657
|
+
default: throw new Error(`Usage: lemony telemetry <status|show|flush|enable|disable [--purge-local]>. Unknown action "${action}".`);
|
|
5007
5658
|
}
|
|
5008
5659
|
};
|
|
5009
5660
|
const telemetryStatus = async (repoRoot) => {
|
|
@@ -5178,6 +5829,53 @@ const designTokensExport = async (args) => {
|
|
|
5178
5829
|
console.log(`design-tokens export plan: ${creates} to create, ${updates} to update (tool-only variables are never deleted).`);
|
|
5179
5830
|
if (out !== void 0) console.log(` projection written to ${out}`);
|
|
5180
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
|
+
};
|
|
5181
5879
|
const spinoff = async (args) => {
|
|
5182
5880
|
const harnessVersion = await readHarnessVersion();
|
|
5183
5881
|
const result = await runSpinoff({
|
|
@@ -5307,6 +6005,9 @@ const main = async () => {
|
|
|
5307
6005
|
case "design-tokens":
|
|
5308
6006
|
await designTokens(args);
|
|
5309
6007
|
return;
|
|
6008
|
+
case "review-ledger":
|
|
6009
|
+
await reviewLedger(args);
|
|
6010
|
+
return;
|
|
5310
6011
|
case "spinoff":
|
|
5311
6012
|
await spinoff(args);
|
|
5312
6013
|
return;
|