@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.
- package/dist/cli/commands/agent.js +4 -3
- package/dist/cli/commands/init.js +23 -34
- package/dist/cli/lib/help.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/shared/agents.js +36 -7
- 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);
|
|
@@ -24,17 +24,16 @@ export function detectProjectName(cwd) {
|
|
|
24
24
|
return path.basename(cwd);
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
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;
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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) {
|
package/dist/cli/lib/help.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
127
|
-
Compass index, and print the
|
|
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,
|
|
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, {
|
package/dist/shared/agents.js
CHANGED
|
@@ -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
|
-
//
|
|
145
|
-
// ai-specs/
|
|
146
|
-
// folder
|
|
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}/`,
|
|
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
|
|
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
|
|
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) =>
|
|
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).
|
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
|
+
}
|