@esneiderbravo/speclaw 2.0.15 → 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);
@@ -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, {
@@ -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.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },