@lemoncode/lemony 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/catalog/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.4.1
@@ -44,7 +44,9 @@ the task branch before invoking you, so you are handed a real `<id>` from the st
44
44
  acceptance criteria. Always include the unwanted-behavior (`If … then …`) paths.
45
45
  - `design.md` — files, functions/interfaces, approach, edge cases, testing.
46
46
  - `tasks.md` — atomic, ordered checkboxes (vertical slices for TDD), each
47
- referencing the requirements it satisfies, grouped under **risk-sized step
47
+ referencing the requirements it satisfies as a comma-separated `(R<n>, R<m>)`
48
+ list that ends a line (rule stated in `prd-to-spec`; a validator reads it),
49
+ grouped under **risk-sized step
48
50
  headers** (grouping criterion in `prd-to-spec`) — in step-by-step mode the
49
51
  loop runs one implement→review→checkpoint cycle per group, and the human
50
52
  approves the grouping with the rest of the spec.
@@ -193,8 +193,28 @@ of the spec. Grouping criterion:
193
193
  ## Group 2 — <name> _(<one-line boundary rationale>)_ [risk: data-loss]
194
194
 
195
195
  - [ ] T3 — <error path> (R2, R3)
196
+ - [ ] **T4 — <a task that needs detail>.**
197
+ <detail prose, as many indented lines as it needs, with the ref list
198
+ ending the last one> (R3, R4)
196
199
  ```
197
200
 
201
+ **Ref list — one rule, read by a script.** A task's requirement refs are one
202
+ comma-separated `(R<n>, R<m>)` group that **ends a line**: the end of the task's first
203
+ line, or the end of one continuation line (a line of its own, or the end of a detail
204
+ line). Nothing after the closing paren on that line, not even a period. No ranges —
205
+ `(R1–R4)` is not a list, write `(R1, R2, R3, R4)` — and no prose inside the parens.
206
+
207
+ **Mentions.** A `(R<n>` on the task's first line, or right after the title's bold
208
+ close, is always read as the declaration: it is reported if it does not end the line,
209
+ even when a list ends a later line. Keep every other `(R<n>)` mention in detail prose
210
+ **mid-line**. When two lists end continuation lines the validator cannot tell which is
211
+ the declaration and reports the task; on a task that declares no list, the one
212
+ line-ending mention is read as its declaration, and a mid-line one is reported.
213
+
214
+ The review evidence ledger's validator enumerates each group's review slice from these
215
+ refs; a task it cannot read is reported as `malformed-task-refs` — a spec defect that
216
+ pauses the loop, never read as "declares none".
217
+
198
218
  Rules: order so the first task is a tracer bullet; never "write all tests" then
199
219
  "write all code"; keep each task small enough to verify on its own. Grouping never
200
220
  changes task granularity — checkboxes stay atomic and TDD runs per task; only review
package/dist/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { basename, delimiter, dirname, extname, join, relative, resolve, sep } f
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";
8
- import { isMap, parse, parseDocument } from "yaml";
8
+ import { parse, parseDocument } from "yaml";
9
9
  import { z } from "zod";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
11
  import { constants } from "node:fs";
@@ -230,8 +230,6 @@ const setConfigValues = (rawYaml, updates) => {
230
230
  }
231
231
  for (const key of DEPRECATED_CONFIG_KEYS) if (doc.has(key)) doc.delete(key);
232
232
  for (const key of DEPRECATED_PATHS_KEYS) if (doc.hasIn(["paths", key])) doc.deleteIn(["paths", key]);
233
- const pathsNode = doc.get("paths", true);
234
- if (isMap(pathsNode) && pathsNode.items.length === 0) doc.delete("paths");
235
233
  for (const key of DEPRECATED_IMPLEMENTATION_KEYS) if (doc.hasIn(["implementation", key])) doc.deleteIn(["implementation", key]);
236
234
  return doc.toString();
237
235
  };
@@ -2105,8 +2103,9 @@ const RISK_MARKER_ALL = /\[risk:/gi;
2105
2103
  const RISK_MARKER_LOOKALIKE = /\[\s*risks?\s*:/i;
2106
2104
  const TASK_LINE = /^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*|__|`)?\s*(T\d+)\b/;
2107
2105
  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+)/;
2106
+ const TASK_REFS_LOOKALIKE_ALL = /\(\s*R\d+/g;
2107
+ const TASK_TITLE_WRAPPER = /^\s*[-*+]\s*\[[ xX]\]\s*(\*\*|__)/;
2108
+ const TASK_REFS_AFTER_TITLE_CLOSE = /^\s*(\(\s*R\d+)/;
2110
2109
  const REQUIREMENT_LINE = /^\s*-\s*\*\*(R\d+)\*\*/;
2111
2110
  const TEST_FILE = /(^|\/)__tests__\/|\.(spec|test)\.[cm]?[jt]sx?$/;
2112
2111
  //#endregion
@@ -2209,7 +2208,7 @@ const parseTaskBlock = (id, parts) => {
2209
2208
  id,
2210
2209
  requirementRefs: [...new Set(requirementRefs)]
2211
2210
  },
2212
- malformedRefs: refs === null && TASK_REFS_LOOKALIKE.test(block)
2211
+ malformedRefs: refs === null && hasRefLookalike(parts)
2213
2212
  };
2214
2213
  };
2215
2214
  const declarationAt = (parts, block, opener) => {
@@ -2230,17 +2229,40 @@ const declarationClosesLine = (parts, end) => {
2230
2229
  return /^(?:\*\*|__)/.test(tail) && lineEnds.has(end + 2);
2231
2230
  };
2232
2231
  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;
2232
+ const first = parts[0] ?? "";
2233
+ const attempted = [];
2234
+ for (const lookalike of first.matchAll(TASK_REFS_LOOKALIKE_ALL)) {
2235
+ if (isLinkTarget(first, lookalike.index)) continue;
2236
+ attempted.push(lookalike.index);
2237
+ break;
2238
+ }
2239
+ const afterTitle = afterTitleClose(first, block);
2240
+ if (afterTitle !== void 0) attempted.push(afterTitle);
2241
+ if (attempted.length > 0) return Math.min(...attempted);
2242
+ const closing = [];
2243
+ let offset = first.length + 1;
2239
2244
  for (const part of parts.slice(1)) {
2240
- if (/^\(\s*R\d+/.test(part)) candidates.push(offset);
2245
+ for (const lookalike of part.matchAll(TASK_REFS_LOOKALIKE_ALL)) {
2246
+ if (isLinkTarget(part, lookalike.index)) continue;
2247
+ const at = offset + lookalike.index;
2248
+ if (declarationAt(parts, block, at) !== null) closing.push(at);
2249
+ }
2241
2250
  offset += part.length + 1;
2242
2251
  }
2243
- return candidates.length === 0 ? void 0 : Math.min(...candidates);
2252
+ return closing.length === 1 ? closing[0] : void 0;
2253
+ };
2254
+ const hasRefLookalike = (parts) => parts.some((part) => [...part.matchAll(TASK_REFS_LOOKALIKE_ALL)].some((lookalike) => !isLinkTarget(part, lookalike.index)));
2255
+ const isLinkTarget = (line, index) => line[index - 1] === "]";
2256
+ const afterTitleClose = (first, block) => {
2257
+ const wrapper = TASK_TITLE_WRAPPER.exec(first);
2258
+ const marker = wrapper?.[1];
2259
+ if (!wrapper || marker === void 0) return void 0;
2260
+ const close = block.indexOf(marker, wrapper[0].length);
2261
+ if (close < 0) return void 0;
2262
+ const tail = block.slice(close + marker.length);
2263
+ const opener = TASK_REFS_AFTER_TITLE_CLOSE.exec(tail);
2264
+ if (!opener) return void 0;
2265
+ return close + marker.length + opener[0].indexOf("(");
2244
2266
  };
2245
2267
  const parseRiskMarker = (line) => {
2246
2268
  const marker = RISK_MARKER.exec(line);
@@ -2381,7 +2403,7 @@ const runLedgerValidate = async (inputs) => {
2381
2403
  });
2382
2404
  for (const id of spec.malformedTaskRefs) problems.push({
2383
2405
  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.`,
2406
+ message: `${id} carries a "(R<n>"-shaped ref list that is not a declaration: a declaration is one comma-separated "(R1, R2)" group that ends a line — no ranges ("R1–R3"), nothing after the closing paren on that line — at the end of the task's first line, right after the title's bold close, or the only group ending a continuation line. Left as-is it reads as a task that references no requirement, and its slice shrinks to itself.`,
2385
2407
  subject: id
2386
2408
  });
2387
2409
  const groups = resolveGroups(spec.groups, address, problems);
@@ -3039,7 +3061,8 @@ const prunePrefix = async (repoRoot, options = {}) => {
3039
3061
  }
3040
3062
  if (sentOffset > size) return { prunedBytes: 0 };
3041
3063
  const stateDir = stateDirOf(repoRoot);
3042
- const drainPath = join(stateDir, `${DRAINING_BASENAME}.${`${process.pid}-${randomBytes(4).toString("hex")}`}`);
3064
+ const id = `${process.pid}-${randomBytes(4).toString("hex")}`;
3065
+ const drainPath = join(stateDir, `${DRAINING_BASENAME}.${id}`);
3043
3066
  const metaPath = `${drainPath}.meta`;
3044
3067
  await mkdir(stateDir, { recursive: true });
3045
3068
  await writeFile(metaPath, JSON.stringify({ sent_offset: sentOffset }));
@@ -3080,14 +3103,15 @@ const appendRejects = async (repoRoot, rejects, now) => {
3080
3103
  const path = join(repoRoot, REJECTED_RELPATH);
3081
3104
  await mkdir(dirname(path), { recursive: true });
3082
3105
  const ts = now().toISOString();
3083
- await appendFile(path, `${rejects.map((reject) => {
3106
+ const body = rejects.map((reject) => {
3084
3107
  const entry = {
3085
3108
  ts,
3086
3109
  reason: reject.reason,
3087
3110
  line: reject.line
3088
3111
  };
3089
3112
  return JSON.stringify(entry);
3090
- }).join("\n")}\n`);
3113
+ }).join("\n");
3114
+ await appendFile(path, `${body}\n`);
3091
3115
  return rejects.length;
3092
3116
  };
3093
3117
  //#endregion
@@ -3759,7 +3783,8 @@ const checkVersionPin = (deps, config) => {
3759
3783
  };
3760
3784
  const checkCliResolution = async (deps) => {
3761
3785
  const name = "cli-resolution";
3762
- if (await isExecutable(join(deps.repoRoot, "node_modules", ".bin", "lemony"))) return {
3786
+ const localBin = join(deps.repoRoot, "node_modules", ".bin", LEMONY_BIN);
3787
+ if (await isExecutable(localBin)) return {
3763
3788
  name,
3764
3789
  status: "ok",
3765
3790
  detail: `Telemetry CLI resolves from node_modules/.bin/${LEMONY_BIN} (the durable setup).`
@@ -4236,7 +4261,8 @@ const snapshotDestPaths = (version, bundle) => {
4236
4261
  ];
4237
4262
  };
4238
4263
  const writeSnapshot = async (repoRoot, version, bundle) => {
4239
- const staging = join(snapshotsRootDir(repoRoot), STAGING_DIR);
4264
+ const root = snapshotsRootDir(repoRoot);
4265
+ const staging = join(root, STAGING_DIR);
4240
4266
  await assertNoSymlinkTraversal(repoRoot, snapshotDestPaths(version, bundle));
4241
4267
  await rm(staging, {
4242
4268
  recursive: true,
@@ -4595,9 +4621,10 @@ const COMPANION_DOCS = [
4595
4621
  "partition"
4596
4622
  ];
4597
4623
  const renderFile = async (templatePath, relPath, vars) => {
4624
+ const template = await readFile(templatePath, "utf8");
4598
4625
  return {
4599
4626
  relPath,
4600
- content: renderTemplate(await readFile(templatePath, "utf8"), vars),
4627
+ content: renderTemplate(template, vars),
4601
4628
  executable: false
4602
4629
  };
4603
4630
  };
@@ -4673,7 +4700,8 @@ const appendGitignoreBlock = async (repoRoot) => {
4673
4700
  await writeFile(gitignorePath, next);
4674
4701
  return ".gitignore";
4675
4702
  }
4676
- await writeFile(gitignorePath, `${current}${current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n"}${GITIGNORE_BLOCK}\n`);
4703
+ const prefix = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
4704
+ await writeFile(gitignorePath, `${current}${prefix}${GITIGNORE_BLOCK}\n`);
4677
4705
  return ".gitignore";
4678
4706
  };
4679
4707
  const removeGitignoreBlock = async (repoRoot) => {
@@ -4834,9 +4862,10 @@ const runInstall = async (options) => {
4834
4862
  const losers = await collectLosers(repoRoot, actions);
4835
4863
  let snapshotVersion = null;
4836
4864
  if (losers.length > 0) {
4865
+ const baseline = new Map(vendorFiles.map((file) => [file.relPath, file.content]));
4837
4866
  await writeSnapshot(repoRoot, vendorVersion, {
4838
4867
  working: losers,
4839
- baseline: new Map(vendorFiles.map((file) => [file.relPath, file.content]))
4868
+ baseline
4840
4869
  });
4841
4870
  snapshotVersion = vendorVersion;
4842
4871
  }
@@ -4893,7 +4922,8 @@ const runInstall = async (options) => {
4893
4922
  };
4894
4923
  };
4895
4924
  const collectLosers = (repoRoot, actions) => {
4896
- return collectWorkingFiles(repoRoot, actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath));
4925
+ const losingPaths = actions.filter((action) => action.kind === "pick-vendor").map((action) => action.relPath);
4926
+ return collectWorkingFiles(repoRoot, losingPaths);
4897
4927
  };
4898
4928
  //#endregion
4899
4929
  //#region src/install/resolve-collision.ts
@@ -4932,12 +4962,14 @@ const runReconcile = async (inputs) => {
4932
4962
  const fromVersion = config.vendor_version;
4933
4963
  const target = config.target;
4934
4964
  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);
4965
+ const rawConfig = await readFile(join(repoRoot, HARNESS_CONFIG_FILENAME), "utf8");
4966
+ const predatedKeys = findPredatedKeys(rawConfig, fromVersion, toVersion, inputs.configKeySince ?? CONFIG_KEY_SINCE);
4936
4967
  const baselineVersion = await findBaselineVersion(repoRoot);
4937
4968
  const baseline = baselineVersion ? await readBaseline(repoRoot, baselineVersion) : /* @__PURE__ */ new Map();
4938
4969
  const hadBaseline = baseline.size > 0;
4939
4970
  const capabilities = await scanRepo(repoRoot);
4940
- const skills = selectSkills(await readCatalogSkills(vendorRoot), capabilities);
4971
+ const catalogSkills = await readCatalogSkills(vendorRoot);
4972
+ const skills = selectSkills(catalogSkills, capabilities);
4941
4973
  const vendorNew = await materializeVendorFiles({
4942
4974
  vendorRoot,
4943
4975
  target,
@@ -4973,14 +5005,16 @@ const runReconcile = async (inputs) => {
4973
5005
  predatedKeys
4974
5006
  };
4975
5007
  if (hadBaseline) {
5008
+ const working = await collectWorkingFiles(repoRoot, [...baseline.keys()]);
4976
5009
  await writeSnapshot(repoRoot, fromVersion, {
4977
- working: await collectWorkingFiles(repoRoot, [...baseline.keys()]),
5010
+ working,
4978
5011
  baseline
4979
5012
  });
4980
5013
  await rotateSnapshots(repoRoot, config.rollback.keep_snapshots);
4981
5014
  }
4982
5015
  await applyActions(repoRoot, actions);
4983
- const { lostHookCommands } = await resyncSpecialCased(repoRoot, join(vendorRoot, "templates", target));
5016
+ const templateRoot = join(vendorRoot, "templates", target);
5017
+ const { lostHookCommands } = await resyncSpecialCased(repoRoot, templateRoot);
4984
5018
  const labelSync = inputs.runCommand ? await syncLabels(taskStorageRepo, createTaskTrackerProvider(config.task_storage.type, inputs.runCommand)) : null;
4985
5019
  await writeBaseline(repoRoot, toVersion, vendorNew);
4986
5020
  await writeConfigValues(repoRoot, configBumps);
@@ -5014,7 +5048,8 @@ const applyActions = async (repoRoot, actions) => {
5014
5048
  default: return;
5015
5049
  }
5016
5050
  }));
5017
- await pruneEmptyDirs(repoRoot, actions.filter((action) => action.kind === "prune").map((action) => action.relPath));
5051
+ const prunedPaths = actions.filter((action) => action.kind === "prune").map((action) => action.relPath);
5052
+ await pruneEmptyDirs(repoRoot, prunedPaths);
5018
5053
  };
5019
5054
  const summarize = (actions) => {
5020
5055
  const conflictedFiles = [];
@@ -5044,12 +5079,10 @@ const summarize = (actions) => {
5044
5079
  winner: "vendor"
5045
5080
  });
5046
5081
  break;
5047
- case "pick-client":
5048
- pickedFiles.push({
5049
- relPath: action.relPath,
5050
- winner: "client"
5051
- });
5052
- break;
5082
+ case "pick-client": pickedFiles.push({
5083
+ relPath: action.relPath,
5084
+ winner: "client"
5085
+ });
5053
5086
  }
5054
5087
  return {
5055
5088
  conflictedFiles,
@@ -5155,11 +5188,12 @@ const runRollback = async (options) => {
5155
5188
  await assertNoSymlinkTraversal(repoRoot, [...restorePaths, ...removePaths]);
5156
5189
  await Promise.all(bundle.working.map((file) => writeManaged(repoRoot, file.relPath, file.content, file.executable)));
5157
5190
  await Promise.all(removePaths.map((path) => rm(join(repoRoot, path), { force: true })));
5158
- await writeBaseline(repoRoot, target, [...bundle.baseline].map(([relPath, content]) => ({
5191
+ const baselineFiles = [...bundle.baseline].map(([relPath, content]) => ({
5159
5192
  relPath,
5160
5193
  content,
5161
5194
  executable: false
5162
- })));
5195
+ }));
5196
+ await writeBaseline(repoRoot, target, baselineFiles);
5163
5197
  await writeConfigValues(repoRoot, { vendor_version: target });
5164
5198
  return {
5165
5199
  repoRoot,
@@ -5700,16 +5734,18 @@ const telemetryEnable = async (repoRoot) => {
5700
5734
  for (const line of formatTelemetryStatus(report)) console.log(line);
5701
5735
  };
5702
5736
  const telemetrySend = async (repoRoot) => {
5737
+ const endpoint = env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev";
5703
5738
  const result = await sendTelemetry({
5704
5739
  repoRoot,
5705
- endpoint: env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev"
5740
+ endpoint
5706
5741
  });
5707
5742
  console.log(`telemetry ${result.outcome} (${result.segmentsSent} sent, ${result.quarantined} quarantined)`);
5708
5743
  };
5709
5744
  const telemetryFlush = async (repoRoot) => {
5745
+ const endpoint = env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev";
5710
5746
  const result = await sendTelemetry({
5711
5747
  repoRoot,
5712
- endpoint: env["LEMONY_TELEMETRY_ENDPOINT"] ?? "https://lemony-telemetry.lemoncode.workers.dev"
5748
+ endpoint
5713
5749
  });
5714
5750
  for (const line of formatTelemetryFlush(result)) console.log(line);
5715
5751
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemoncode/lemony",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Lemony — a Harness for AI Coding. Vendor package: installer, agent role catalog, generic skill catalog, hooks, and templates for a Spec-Driven Development workflow.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -50,16 +50,16 @@
50
50
  "@types/node": "24.13.3",
51
51
  "husky": "9.1.7",
52
52
  "lint-staged": "17.4.1",
53
- "oxlint": "1.80.0",
53
+ "oxlint": "1.81.0",
54
54
  "prettier": "3.9.6",
55
- "tsdown": "0.22.14",
56
- "tsx": "4.23.12",
55
+ "tsdown": "0.23.0",
56
+ "tsx": "4.23.13",
57
57
  "typescript": "7.0.2",
58
- "vitest": "4.1.11"
58
+ "vitest": "5.0.0"
59
59
  },
60
60
  "dependencies": {
61
61
  "yaml": "2.9.0",
62
- "zod": "4.4.3"
62
+ "zod": "4.5.4"
63
63
  },
64
64
  "scripts": {
65
65
  "build": "tsdown",