@esneiderbravo/speclaw 2.0.15 → 2.0.17

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.
@@ -1,8 +1,9 @@
1
- import { AGENTS, agentById, configureAgent, detectConfiguredAgents } from "../../shared/agents.js";
1
+ import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
2
+ import { addAgent } from "../../modules/foundation/scaffold.js";
2
3
  import { emptyReport } from "../../shared/install.js";
3
4
  import { ui } from "../lib/ui.js";
4
5
  /**
5
- * List configured agents or configure a new one (symlinks + MCP).
6
+ * List configured agents or configure a new one (symlinks, MCP, and hooks).
6
7
  *
7
8
  * @param flags - Parsed flags; `_[0]` is the subcommand (`list`/`add`) and `_[1]` the agent id.
8
9
  */
@@ -26,7 +27,7 @@ export async function runAgent(flags) {
26
27
  process.exit(1);
27
28
  }
28
29
  const report = emptyReport();
29
- configureAgent(cwd, id, report);
30
+ addAgent(cwd, id, report);
30
31
  ui.ok(`${agentById(id).label} configured`);
31
32
  for (const s of report.symlinks)
32
33
  ui.info(s);
@@ -18,6 +18,7 @@ export { resolveChangeDir } from "./paths.js";
18
18
  // Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
19
19
  // without importing this file back (no file-level import cycle).
20
20
  export { HARNESS_STAGES, } from "./types.js";
21
+ import { formatJson } from "../../shared/json.js";
21
22
  export const harnessOps = ["status", "start", "advance", "rework"];
22
23
  const DEFAULT_MAX_REWORK = 3;
23
24
  function specRoot(projectPath) {
@@ -69,7 +70,7 @@ export function readHarness(projectPath, change) {
69
70
  }
70
71
  function writeHarness(projectPath, change, state) {
71
72
  const p = harnessPath(projectPath, change);
72
- fs.writeFileSync(p, JSON.stringify(state, null, 2) + "\n");
73
+ fs.writeFileSync(p, formatJson(state));
73
74
  }
74
75
  function nextAfterExplore(level) {
75
76
  return level === 0 ? "implementing" : "planning";
@@ -314,7 +315,7 @@ export function completeHarnessOnArchive(projectPath, change, note) {
314
315
  { at: nowIso(), from: "archiving", to: "done", op: "advance", note },
315
316
  ],
316
317
  };
317
- fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n");
318
+ fs.writeFileSync(p, formatJson(next));
318
319
  return { completed: true, restore: () => fs.writeFileSync(p, original) };
319
320
  }
320
321
  /**
@@ -1,5 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { commonPrefix, delimit, } from "./types.js";
2
4
  const NESTED_THRESHOLD = 3;
5
+ const LAWS_BLOCK_RE = /<!-- speclaw:laws:start -->[\s\S]*?<!-- speclaw:laws:end -->/;
6
+ /**
7
+ * The agent workflow speclaw relies on (Compass first, Cortex with the Stop
8
+ * hook), emitted in the generated block only when the hand-written part of
9
+ * AGENTS.md lacks it.
10
+ *
11
+ * @remarks
12
+ * `update` never edits the personalized AGENTS.md, so a project scaffolded
13
+ * before Cortex (or one with a hand-written AGENTS.md) never learned to record
14
+ * its work in the lawbook. The generated block is rewritten on every update,
15
+ * so it carries the rule there; a file that already states it pays no tokens.
16
+ *
17
+ * @param projectPath - Project root holding AGENTS.md.
18
+ * @returns The section's lines, or none when AGENTS.md already covers it.
19
+ */
20
+ export function workflowSection(projectPath) {
21
+ let own = "";
22
+ try {
23
+ own = fs.readFileSync(path.join(projectPath, "AGENTS.md"), "utf8").replace(LAWS_BLOCK_RE, "");
24
+ }
25
+ catch {
26
+ // No AGENTS.md yet: the section goes in.
27
+ }
28
+ if (/\bCortex\b/.test(own) && /\bcompass_explore\b/.test(own))
29
+ return [];
30
+ return [
31
+ "## speclaw workflow (generated)",
32
+ "",
33
+ "- **Compass first.** For any code question call `compass_find` / `compass_explore`",
34
+ " before grep or reading files; run `compass_index` first if the graph is missing.",
35
+ "- **Cortex for every change.** Branch `<type>/<slug>`, locate with Compass, implement",
36
+ " the change and its test, run the tests, then stop. In Claude Code the `Stop` hook",
37
+ " (`speclaw ship-on-stop`) records the change in `lawbook/` and runs the gates; agents",
38
+ ' without hooks run `speclaw ship <change> --summary "<what and why>"` once, last.',
39
+ " Work that needs a spec goes through the `cortex` skill.",
40
+ "",
41
+ ];
42
+ }
3
43
  function degradeSection(title, laws) {
4
44
  const lines = [`## ${title}`, ""];
5
45
  for (const law of laws) {
@@ -35,6 +75,7 @@ export const agentsmdDialect = {
35
75
  "_Edit `docs/standards/*.md` or `.speclaw/laws-manifest.json`; do not edit this block._",
36
76
  "",
37
77
  ];
78
+ rootParts.push(...workflowSection(ctx.projectPath));
38
79
  if (global.length)
39
80
  rootParts.push(degradeSection("Global rules", global));
40
81
  const nestedMention = [];
@@ -203,3 +203,34 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
203
203
  ].filter(Boolean);
204
204
  return report;
205
205
  }
206
+ /**
207
+ * Wire one agent into an already-initialized project: its links and MCP entry
208
+ * (via `configureAgent`) plus, for a hook-capable agent, speclaw's hooks.
209
+ *
210
+ * @remarks
211
+ * `configureAgent` alone left a later-added Claude Code without its
212
+ * SessionStart / PostToolUse / Stop hooks, so Compass never refreshed and the
213
+ * Stop hook never shipped the change. The hooks' baseline is recorded in the
214
+ * manifest when the project has one, so a later `update` treats the file as
215
+ * speclaw's own rather than a user edit.
216
+ *
217
+ * @param projectPath - Project root.
218
+ * @param agentId - Agent id from `AGENTS` (e.g. `"claude"`).
219
+ * @param report - Install report mutated in place.
220
+ * @returns Which agents got hooks, were skipped, and any laws rejected for bad globs.
221
+ * @throws When `agentId` is not a known agent.
222
+ */
223
+ export function addAgent(projectPath, agentId, report) {
224
+ configureAgent(projectPath, agentId, report);
225
+ const manifest = readManifest(projectPath);
226
+ const record = {};
227
+ const lawManifest = readLawManifest(projectPath) ?? { version: 1, laws: [] };
228
+ const hooks = installHooks(projectPath, [agentId], lawManifest, report, {
229
+ baselines: manifest?.baselines ?? {},
230
+ record,
231
+ });
232
+ if (manifest && Object.keys(record).length > 0) {
233
+ writeManifest(projectPath, manifest.version, [], record);
234
+ }
235
+ return hooks;
236
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { loadPacks } from "../tools/packs.js";
3
- import { AGENTS, configureAgent } from "../../shared/agents.js";
3
+ import { AGENTS } from "../../shared/agents.js";
4
+ import { addAgent } from "./scaffold.js";
4
5
  import { emptyReport } from "../../shared/install.js";
5
6
  import { refreshAgents } from "../../shared/agents.js";
6
7
  import { installPack } from "../tools/packs.js";
@@ -76,7 +77,7 @@ export function handleSpeclawSetup(args) {
76
77
  if (!args.agent)
77
78
  throw new Error(`speclaw_setup: action 'configure-agent' requires 'agent'`);
78
79
  const report = emptyReport();
79
- configureAgent(args.projectPath, args.agent, report);
80
+ addAgent(args.projectPath, args.agent, report);
80
81
  return report;
81
82
  }
82
83
  case "list-packs":
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { openDb, rehydrateAnchors } from "../compass/db.js";
8
8
  import { NORMALIZER_VERSION } from "../compass/hash.js";
9
9
  import { headSha } from "../../shared/git-history.js";
10
+ import { formatJson } from "../../shared/json.js";
10
11
  const STOPWORDS = new Set([
11
12
  "SHALL",
12
13
  "MUST",
@@ -220,7 +221,7 @@ export function writeAnchorsFile(projectPath, doc) {
220
221
  fs.mkdirSync(dir, { recursive: true });
221
222
  const sorted = [...doc.anchors].sort((a, b) => `${a.requirementId}\0${a.scenarioId}\0${a.symbolName}`.localeCompare(`${b.requirementId}\0${b.scenarioId}\0${b.symbolName}`));
222
223
  const dest = anchorsPath(projectPath, doc.capability);
223
- fs.writeFileSync(dest, JSON.stringify({ ...doc, anchors: sorted }, null, 2) + "\n", "utf8");
224
+ fs.writeFileSync(dest, formatJson({ ...doc, anchors: sorted }), "utf8");
224
225
  return dest;
225
226
  }
226
227
  /** List capability names that already have an anchors file. */
@@ -7,6 +7,7 @@ import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasD
7
7
  import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
8
8
  import { isPlaceholderDelta } from "./scaffold-change.js";
9
9
  import { completeHarnessOnArchive, harnessArchiveBlockers } from "../cortex/harness.js";
10
+ import { formatJson } from "../../shared/json.js";
10
11
  // speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
11
12
  // (proposals, delta specs, changes, archive) but implemented from scratch and
12
13
  // deliberately simpler: a change's specs/ holds the full intended spec for each
@@ -53,7 +54,17 @@ coverage:
53
54
  # EARS requirement linter (strict by default for new projects).
54
55
  ears:
55
56
  severity: strict
56
- vagueWords: [appropriately, properly, as needed, efficiently, user-friendly, robust, adecuadamente, correctamente]
57
+ vagueWords:
58
+ [
59
+ appropriately,
60
+ properly,
61
+ as needed,
62
+ efficiently,
63
+ user-friendly,
64
+ robust,
65
+ adecuadamente,
66
+ correctamente,
67
+ ]
57
68
  silentCodes: []
58
69
  `;
59
70
  const README_MD = `# lawbook/ — the spec-driven workflow (speclaw)
@@ -479,7 +490,7 @@ export function specArchive(projectPath, change, date) {
479
490
  rec.changeType = "bug";
480
491
  rec.resolution = inferBugResolution(fs.readFileSync(bugPath, "utf8"));
481
492
  const cj = path.join(changeDir, "change.json");
482
- fs.writeFileSync(cj, JSON.stringify(rec, null, 2) + "\n");
493
+ fs.writeFileSync(cj, formatJson(rec));
483
494
  }
484
495
  }
485
496
  }
@@ -6,6 +6,7 @@ import { impact } from "../compass/query.js";
6
6
  import { affectedTests } from "../compass/affected.js";
7
7
  import { hotspots } from "../compass/hotspots.js";
8
8
  import { loadAffectedConfig, matchGlob, matchesAny, inferModule, } from "../compass/affected-config.js";
9
+ import { formatJson } from "../../shared/json.js";
9
10
  /** Default thresholds from the adaptive-ceremony roadmap. */
10
11
  export const DEFAULT_THRESHOLDS = {
11
12
  filesTouched: [0, 1, 3, 5],
@@ -376,7 +377,7 @@ export function confirmedLevel(projectPath, change) {
376
377
  export function writeCeremonyRecord(projectPath, change, record) {
377
378
  const p = changeJsonPath(projectPath, change);
378
379
  fs.mkdirSync(path.dirname(p), { recursive: true });
379
- fs.writeFileSync(p, JSON.stringify(record, null, 2) + "\n");
380
+ fs.writeFileSync(p, formatJson(record));
380
381
  }
381
382
  /**
382
383
  * The proposal fields stored in a change's `change.json`, or null when there
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { gatherSignals, loadCeremonyConfig, proposeLevel, setCeremonyLevel, writeCeremonyRecord, } from "./levels.js";
4
+ import { formatJson } from "../../shared/json.js";
4
5
  // Covers: req~feature-draft~1
5
6
  /** Change folder names are kebab-case: lowercase alphanumerics joined by single hyphens. */
6
7
  export const CHANGE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -45,7 +46,7 @@ export function scaffoldChange(projectPath, name, opts) {
45
46
  let record;
46
47
  if (level === undefined) {
47
48
  record = opts.changeType ? { ...stored, changeType: opts.changeType } : { ...stored };
48
- fs.writeFileSync(path.join(changeDir, "change.json"), JSON.stringify(record, null, 2) + "\n");
49
+ fs.writeFileSync(path.join(changeDir, "change.json"), formatJson(record));
49
50
  }
50
51
  else {
51
52
  let confirmed = setCeremonyLevel(projectPath, name, {
@@ -238,7 +238,7 @@ export function shipChange(projectPath, name, opts = {}) {
238
238
  archivedTo = specArchive(projectPath, name, date).archivedTo;
239
239
  }
240
240
  if (gatesPassed && isGitRepo(projectPath))
241
- markShipped(projectPath);
241
+ markShipped(projectPath, name);
242
242
  const t4 = Date.now();
243
243
  const gatesMs = t2 - t1;
244
244
  return {
@@ -278,32 +278,69 @@ function git(projectPath, args) {
278
278
  const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
279
279
  return r.status === 0 ? r.stdout : "";
280
280
  }
281
- /** Fingerprint of the last shipped work; a manual ship and the hook share it. */
281
+ /** The last shipped work; a manual ship and the hook share it. */
282
282
  const SHIP_MARKER = path.join(".speclaw", "ship-last");
283
- function markShipped(projectPath) {
283
+ function currentBranch(projectPath) {
284
+ return git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
285
+ }
286
+ function markShipped(projectPath, change) {
284
287
  const marker = path.join(projectPath, SHIP_MARKER);
285
288
  fs.mkdirSync(path.dirname(marker), { recursive: true });
286
- fs.writeFileSync(marker, workFingerprint(projectPath));
289
+ const body = {
290
+ branch: currentBranch(projectPath),
291
+ change,
292
+ fingerprint: workFingerprint(projectPath),
293
+ };
294
+ fs.writeFileSync(marker, JSON.stringify(body) + "\n");
295
+ }
296
+ /** The last ship marker, or null; a pre-2.0.17 marker (a bare fingerprint) has no change name. */
297
+ function readMarker(projectPath) {
298
+ const marker = path.join(projectPath, SHIP_MARKER);
299
+ if (!fs.existsSync(marker))
300
+ return null;
301
+ const raw = fs.readFileSync(marker, "utf8").trim();
302
+ try {
303
+ const m = JSON.parse(raw);
304
+ if (typeof m.fingerprint === "string") {
305
+ return { branch: m.branch ?? "", change: m.change ?? "", fingerprint: m.fingerprint };
306
+ }
307
+ }
308
+ catch {
309
+ // Legacy marker: the fingerprint alone.
310
+ }
311
+ return { branch: "", change: "", fingerprint: raw };
287
312
  }
288
313
  /**
289
- * Fingerprint of the work outside `lawbook/changes/`: the diff against HEAD
290
- * plus untracked file names. Ship writes under `lawbook/changes/`, so its own
291
- * output never changes the fingerprint.
314
+ * Fingerprint of the branch's work outside `lawbook/changes/`: the working tree
315
+ * diffed against the merge base with `main`/`master`, plus untracked files
316
+ * (names and contents). Committing that work leaves it unchanged — a commit is not new work —
317
+ * and ship writes under `lawbook/changes/`, so its own output never changes it.
318
+ * Without a merge base it falls back to the diff against HEAD plus HEAD itself.
292
319
  */
293
320
  function workFingerprint(projectPath) {
294
- const diff = git(projectPath, ["diff", "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
295
- const untracked = git(projectPath, ["ls-files", "--others", "--exclude-standard"])
296
- .split("\n")
297
- .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"))
298
- .join("\n");
299
- const head = git(projectPath, ["rev-parse", "HEAD"]).trim();
300
- return createHash("sha256").update(`${head}\n${diff}\n${untracked}`).digest("hex");
321
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
322
+ const diff = git(projectPath, ["diff", base ?? "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
323
+ const hash = createHash("sha256");
324
+ for (const f of git(projectPath, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
325
+ if (!f || f.startsWith("lawbook/changes/") || f.startsWith(".speclaw/"))
326
+ continue;
327
+ // Contents, not just names: editing a new, not-yet-added file is new work.
328
+ hash.update(`${f}\n`);
329
+ try {
330
+ hash.update(fs.readFileSync(path.join(projectPath, f)));
331
+ }
332
+ catch {
333
+ // Vanished or unreadable since listing: its name stands in.
334
+ }
335
+ }
336
+ const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
337
+ return hash.update(`\n${anchor}\n${diff}`).digest("hex");
301
338
  }
302
339
  /**
303
340
  * The `Stop` hook body: when the agent ends a turn on a feature branch with
304
341
  * work that changed since the last ship, run {@link shipChange} for the change
305
- * named after the branch. Skips in milliseconds otherwise, so a turn that only
306
- * answered a question costs nothing.
342
+ * last shipped on this branch, else the one named after the branch. Skips in
343
+ * milliseconds otherwise, so a turn that only answered a question costs nothing.
307
344
  *
308
345
  * @param projectPath - Project root.
309
346
  * @returns Why it skipped, or the ship result.
@@ -311,15 +348,19 @@ function workFingerprint(projectPath) {
311
348
  export function shipOnStop(projectPath) {
312
349
  if (!isGitRepo(projectPath))
313
350
  return { skipped: "not-git" };
314
- const branch = git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
351
+ const branch = currentBranch(projectPath);
315
352
  if (!branch || ["main", "master", "HEAD"].includes(branch))
316
353
  return { skipped: "base-branch" };
317
354
  if (branchFiles(projectPath).length === 0)
318
355
  return { skipped: "no-changes" };
319
- const marker = path.join(projectPath, SHIP_MARKER);
320
- if (fs.existsSync(marker) && fs.readFileSync(marker, "utf8") === workFingerprint(projectPath)) {
356
+ const marker = readMarker(projectPath);
357
+ if (marker?.fingerprint === workFingerprint(projectPath)) {
321
358
  return { skipped: "unchanged-since-last-ship" };
322
359
  }
323
- const change = changeNameForBranch(branch);
360
+ // A change shipped by name on this branch keeps receiving the branch's later
361
+ // work; the branch name would point at another (possibly archived) change.
362
+ const change = marker && marker.branch === branch && marker.change
363
+ ? marker.change
364
+ : changeNameForBranch(branch);
324
365
  return { skipped: null, change, result: shipChange(projectPath, change) };
325
366
  }
@@ -39,6 +39,10 @@ export const AGENTS = [
39
39
  label: "Generic (AGENTS.md)",
40
40
  ideDir: ".agents",
41
41
  linkTargets: ["skills", "commands", "agents", "rules"],
42
+ // The default agent must still reach Compass and the lawbook tools: without
43
+ // a server entry the agent contract (Compass first) cannot be followed.
44
+ // `.mcp.json` is the project-scoped MCP file Claude Code reads.
45
+ mcpFile: ".mcp.json",
42
46
  },
43
47
  ];
44
48
  /**
@@ -105,7 +105,22 @@ export function ensureGitignore(projectPath, entry, comment, report) {
105
105
  if (!content.endsWith("\n"))
106
106
  content += "\n";
107
107
  }
108
- content += `\n# ${comment}\n${entry}\n`;
108
+ // Entries sharing a comment join its block instead of repeating the comment
109
+ // once per entry (one agent folder adds several).
110
+ const lines = content.split("\n");
111
+ const header = lines.lastIndexOf(`# ${comment}`);
112
+ if (header !== -1) {
113
+ let end = header + 1;
114
+ while (end < lines.length && lines[end].trim() !== "")
115
+ end++;
116
+ lines.splice(end, 0, entry);
117
+ content = lines.join("\n");
118
+ if (!content.endsWith("\n"))
119
+ content += "\n";
120
+ }
121
+ else {
122
+ content += `\n# ${comment}\n${entry}\n`;
123
+ }
109
124
  fs.writeFileSync(gitignorePath, content);
110
125
  report.written.push(`${gitignorePath} (${entry})`);
111
126
  }
@@ -0,0 +1,78 @@
1
+ // JSON that speclaw writes into committed paths (lawbook/) must survive a host
2
+ // repo's `prettier --check .`. Prettier keeps an object expanded when it was
3
+ // written expanded, but collapses an array onto one line whenever it fits the
4
+ // print width — so `JSON.stringify(v, null, 2)` arrays like `["a"]` fail it.
5
+ const PRINT_WIDTH = 100;
6
+ /** True for a non-empty array or object, which Prettier never prints flat inside an array. */
7
+ function isNonEmptyObject(v) {
8
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length > 0;
9
+ }
10
+ /** Prettier breaks an array of two or more arrays that each hold two or more elements. */
11
+ function mustBreak(arr) {
12
+ return arr.length > 1 && arr.every((e) => Array.isArray(e) && e.length > 1);
13
+ }
14
+ /** The one-line form of a value, or null when Prettier would never print it flat. */
15
+ function flat(v) {
16
+ if (Array.isArray(v)) {
17
+ if (v.length === 0)
18
+ return "[]";
19
+ if (mustBreak(v) || v.some(isNonEmptyObject))
20
+ return null;
21
+ const parts = v.map(flat);
22
+ if (parts.some((p) => p === null))
23
+ return null;
24
+ return `[${parts.join(", ")}]`;
25
+ }
26
+ if (typeof v === "object" && v !== null)
27
+ return Object.keys(v).length === 0 ? "{}" : null;
28
+ return JSON.stringify(v);
29
+ }
30
+ function render(v, depth, prefix, comma) {
31
+ const pad = " ".repeat(depth);
32
+ const tail = comma ? "," : "";
33
+ const one = flat(v);
34
+ if (one !== null && (!Array.isArray(v) || (pad + prefix + one + tail).length <= PRINT_WIDTH)) {
35
+ return pad + prefix + one + tail;
36
+ }
37
+ if (Array.isArray(v)) {
38
+ if (v.every((e) => typeof e === "number")) {
39
+ // Prettier fills number arrays: as many per line as fit.
40
+ const inner = " ".repeat(depth + 1);
41
+ const rows = [];
42
+ let row = "";
43
+ v.forEach((n, i) => {
44
+ const item = JSON.stringify(n) + (i < v.length - 1 ? "," : "");
45
+ if (row && (inner + row + " " + item).length > PRINT_WIDTH) {
46
+ rows.push(inner + row);
47
+ row = item;
48
+ }
49
+ else
50
+ row = row ? `${row} ${item}` : item;
51
+ });
52
+ rows.push(inner + row);
53
+ return `${pad}${prefix}[\n${rows.join("\n")}\n${pad}]${tail}`;
54
+ }
55
+ const items = v.map((e, i) => render(e, depth + 1, "", i < v.length - 1));
56
+ return `${pad}${prefix}[\n${items.join("\n")}\n${pad}]${tail}`;
57
+ }
58
+ const entries = Object.entries(v);
59
+ const props = entries.map(([k, val], i) => render(val, depth + 1, `${JSON.stringify(k)}: `, i < entries.length - 1));
60
+ return `${pad}${prefix}{\n${props.join("\n")}\n${pad}}${tail}`;
61
+ }
62
+ /**
63
+ * Serialize a value as 2-space JSON in the layout Prettier gives it, so files
64
+ * speclaw writes into a host repo stay green under `prettier --check`.
65
+ *
66
+ * @remarks
67
+ * Objects stay expanded (as `JSON.stringify` writes them); arrays go on one
68
+ * line when they fit in 100 columns and hold no non-empty object. Values are
69
+ * normalized through `JSON.stringify` first, so `undefined` fields drop out and
70
+ * `toJSON` applies exactly as before.
71
+ *
72
+ * @param value - Any JSON-serializable value.
73
+ * @returns The formatted JSON text with a trailing newline.
74
+ */
75
+ export function formatJson(value) {
76
+ const normalized = JSON.parse(JSON.stringify(value));
77
+ return render(normalized, 0, "", false) + "\n";
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },