@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.
- package/dist/cli/commands/agent.js +4 -3
- package/dist/modules/cortex/harness.js +3 -2
- package/dist/modules/foundation/dialects/agentsmd.js +41 -0
- package/dist/modules/foundation/scaffold.js +31 -0
- package/dist/modules/foundation/setup-tool.js +3 -2
- package/dist/modules/lawbook/anchors.js +2 -1
- package/dist/modules/lawbook/engine.js +13 -2
- package/dist/modules/lawbook/levels.js +2 -1
- package/dist/modules/lawbook/scaffold-change.js +2 -1
- package/dist/modules/lawbook/ship.js +61 -20
- package/dist/shared/agents.js +4 -0
- package/dist/shared/install.js +16 -1
- package/dist/shared/json.js +78 -0
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { AGENTS, agentById,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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,
|
|
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"),
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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 =
|
|
320
|
-
if (
|
|
356
|
+
const marker = readMarker(projectPath);
|
|
357
|
+
if (marker?.fingerprint === workFingerprint(projectPath)) {
|
|
321
358
|
return { skipped: "unchanged-since-last-ship" };
|
|
322
359
|
}
|
|
323
|
-
|
|
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
|
}
|
package/dist/shared/agents.js
CHANGED
|
@@ -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
|
/**
|
package/dist/shared/install.js
CHANGED
|
@@ -105,7 +105,22 @@ export function ensureGitignore(projectPath, entry, comment, report) {
|
|
|
105
105
|
if (!content.endsWith("\n"))
|
|
106
106
|
content += "\n";
|
|
107
107
|
}
|
|
108
|
-
|
|
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
|
+
}
|