@esneiderbravo/speclaw 2.0.14 → 2.0.16

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);
@@ -24,17 +24,16 @@ export function detectProjectName(cwd) {
24
24
  return path.basename(cwd);
25
25
  }
26
26
  /**
27
- * Interactive setup: pick agents (and packs when any exist), scaffold, index,
27
+ * Setup: create `.agents/` (plus any `--agents`), pick packs when any exist, scaffold, index,
28
28
  * and print the handoff prompt.
29
29
  *
30
- * @param flags - Parsed flags; runs interactively on a TTY unless `--agents`, `--yes`, or
31
- * `-y` is set. Honors `--project-name`, `--packs`, and `--no-index`.
30
+ * @param flags - Parsed flags; only the pack picker is interactive (on a TTY unless
31
+ * `--agents`, `--yes`, or `-y` is set). Honors `--project-name`, `--packs`, and `--no-index`.
32
32
  */
33
33
  export async function runInit(flags) {
34
34
  const cwd = process.cwd();
35
35
  const projectName = flags["project-name"] || detectProjectName(cwd);
36
36
  const interactive = Boolean(process.stdin.isTTY) && !flags.agents && !flags.yes && !flags.y;
37
- let agents;
38
37
  let packs;
39
38
  const availablePacks = loadPacks();
40
39
  const packIds = Object.keys(availablePacks);
@@ -50,36 +49,26 @@ export async function runInit(flags) {
50
49
  `Later, ${ui.code("speclaw update")} upgrades itself through npx.`);
51
50
  ui.plain();
52
51
  }
53
- if (interactive) {
54
- // Covers: req~remove-agents-pack~1
55
- const prompts = {
56
- agents: () => clack.multiselect({
57
- message: "Which agents do you use? (space to select)",
58
- options: AGENTS.map((a) => ({ value: a.id, label: a.label })),
59
- initialValues: ["agents"],
60
- required: true,
61
- }),
62
- };
63
- if (packIds.length > 0) {
64
- prompts.packs = () => clack.multiselect({
65
- message: "Which tool packs to install?",
66
- options: packIds.map((id) => ({
67
- value: id,
68
- label: id,
69
- hint: availablePacks[id].description.slice(0, 50),
70
- })),
71
- initialValues: [],
72
- required: false,
73
- });
74
- }
75
- const answers = await clack.group(prompts, { onCancel: () => process.exit(1) });
76
- agents = answers.agents;
77
- packs = answers.packs ?? [];
78
- }
79
- else {
80
- // Default: one agent-facing folder, `.agents/`, linked into ai-specs/.
81
- agents = list(flags.agents).length ? list(flags.agents) : ["agents"];
82
- packs = list(flags.packs);
52
+ // Agents are never prompted for: init creates one agent-facing folder,
53
+ // `.agents/`, linked into ai-specs/. Other IDE folders only via `--agents`
54
+ // or `speclaw agent add`.
55
+ const agents = list(flags.agents).length ? list(flags.agents) : ["agents"];
56
+ packs = list(flags.packs);
57
+ // Covers: req~remove-agents-pack~1
58
+ if (interactive && packIds.length > 0 && !packs.length) {
59
+ const answer = await clack.multiselect({
60
+ message: "Which tool packs to install?",
61
+ options: packIds.map((id) => ({
62
+ value: id,
63
+ label: id,
64
+ hint: availablePacks[id].description.slice(0, 50),
65
+ })),
66
+ initialValues: [],
67
+ required: false,
68
+ });
69
+ if (clack.isCancel(answer))
70
+ process.exit(1);
71
+ packs = answer;
83
72
  }
84
73
  const unknownAgents = agents.filter((a) => !agentById(a));
85
74
  if (unknownAgents.length) {
@@ -13,7 +13,7 @@ Install globally so the command is always available:
13
13
  npm i -g @esneiderbravo/speclaw (or run npx @esneiderbravo/speclaw@latest init)
14
14
 
15
15
  Setup
16
- init Interactive setup: pick agents, scaffold, index, get the prompt
16
+ init Setup: .agents/ linked into ai-specs/, scaffold, index, get the prompt
17
17
  (--minimal omits setup/lifecycle MCP tools)
18
18
  update Re-run itself at the latest version via npx, then apply project
19
19
  migrations (--check reports version only; --no-self-update
@@ -123,8 +123,9 @@ you rarely run it by hand.
123
123
  name: "init",
124
124
  usage: `Usage: speclaw init [options]
125
125
 
126
- Interactive setup: pick agents, scaffold the constitution and lawbook, build the
127
- Compass index, and print the starting prompt. Prefer
126
+ Setup without an agent picker: create .agents/ linked into ai-specs/ (gitignored),
127
+ scaffold the constitution and lawbook, build the Compass index, and print the
128
+ starting prompt. Prefer
128
129
  npx @esneiderbravo/speclaw@latest init so the scaffold matches the latest release.
129
130
 
130
131
  Options
@@ -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, {
@@ -11,6 +11,7 @@ export const AGENTS = [
11
11
  linkTargets: ["skills", "commands", "agents"],
12
12
  mcpFile: ".mcp.json",
13
13
  hooks: { file: ".claude/settings.json", key: "hooks" },
14
+ ownedPaths: [".claude/rules/speclaw"],
14
15
  },
15
16
  {
16
17
  id: "cursor",
@@ -38,6 +39,10 @@ export const AGENTS = [
38
39
  label: "Generic (AGENTS.md)",
39
40
  ideDir: ".agents",
40
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",
41
46
  },
42
47
  ];
43
48
  /**
@@ -141,11 +146,23 @@ export function configureAgent(projectPath, agentId, report) {
141
146
  if (!agent)
142
147
  throw new Error(`Unknown agent "${agentId}". Known: ${AGENTS.map((a) => a.id).join(", ")}`);
143
148
  const ideRoot = path.join(projectPath, agent.ideDir);
144
- // An agent folder speclaw creates holds only links into the gitignored
145
- // ai-specs/ and per-developer wiring: keep it out of version control. A
146
- // folder that already existed is the user's and is left as it is.
149
+ // Everything speclaw puts in an agent folder is links into the gitignored
150
+ // ai-specs/ plus per-developer wiring: keep it out of version control. A
151
+ // folder speclaw creates is ignored whole; in a folder that already existed
152
+ // (the IDE's own, e.g. `.claude/settings.local.json`) only speclaw's entries are.
153
+ const comment = `speclaw ${agent.label} links (regenerated by init; never commit)`;
147
154
  if (!fs.existsSync(ideRoot)) {
148
- ensureGitignore(projectPath, `${agent.ideDir}/`, `speclaw ${agent.label} links (regenerated by init; never commit)`, report);
155
+ ensureGitignore(projectPath, `${agent.ideDir}/`, comment, report);
156
+ }
157
+ else {
158
+ const owned = agent.linkTargets
159
+ .filter((t) => fs.existsSync(path.join(projectPath, "ai-specs", t)))
160
+ .map((t) => `${agent.ideDir}/${t}`)
161
+ .concat(agent.ownedPaths ?? []);
162
+ if (agent.hooks && !fs.existsSync(path.join(projectPath, agent.hooks.file)))
163
+ owned.push(agent.hooks.file);
164
+ for (const entry of owned)
165
+ ensureGitignore(projectPath, entry, comment, report);
149
166
  }
150
167
  for (const target of agent.linkTargets) {
151
168
  const sourceDir = path.join(projectPath, "ai-specs", target);
@@ -164,13 +181,25 @@ export function configureAgent(projectPath, agentId, report) {
164
181
  writeMcpConfig(projectPath, agent.mcpFile, report);
165
182
  }
166
183
  /**
167
- * List the agents already set up in this project (their IDE dir exists).
184
+ * List the agents speclaw set up in this project: those whose IDE dir holds at
185
+ * least one speclaw link into ai-specs/. A bare IDE dir is not enough — Claude
186
+ * Code and Cursor create `.claude/` / `.cursor/` on their own, and treating
187
+ * that as "configured" made `update` populate folders nobody chose.
168
188
  *
169
189
  * @param projectPath - Project root to inspect.
170
- * @returns The ids of agents whose IDE directory is present.
190
+ * @returns The ids of agents with a speclaw link in their IDE directory.
171
191
  */
172
192
  export function detectConfiguredAgents(projectPath) {
173
- return AGENTS.filter((a) => fs.existsSync(path.join(projectPath, a.ideDir))).map((a) => a.id);
193
+ return AGENTS.filter((a) => a.linkTargets.some((t) => isAiSpecsLink(path.join(projectPath, a.ideDir, t), t))).map((a) => a.id);
194
+ }
195
+ /** True when `p` is a symlink whose target ends in `ai-specs/<target>`. */
196
+ function isAiSpecsLink(p, target) {
197
+ try {
198
+ return fs.readlinkSync(p).replace(/\\/g, "/").endsWith(`ai-specs/${target}`);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
174
203
  }
175
204
  /**
176
205
  * Re-run configuration for every already-configured agent (e.g. after adding a pack).
@@ -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.14",
3
+ "version": "2.0.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },