@esneiderbravo/speclaw 2.0.13 → 2.0.15

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.
@@ -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,35 +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: ["claude"],
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
- agents = list(flags.agents).length ? list(flags.agents) : ["claude"];
81
- 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;
82
72
  }
83
73
  const unknownAgents = agents.filter((a) => !agentById(a));
84
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,13 +123,14 @@ 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
131
132
  --yes, -y Accept the defaults without prompting
132
- --agents <ids> Comma-separated agents to configure (claude, cursor, …)
133
+ --agents <ids> Agents to configure (default: agents → only .agents/; also claude, cursor, codex, windsurf)
133
134
  --packs <ids> Comma-separated tool packs to install
134
135
  --project-name <name> Project name for the templates
135
136
  --minimal Omit setup/lifecycle MCP tools
@@ -42,8 +42,8 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
42
42
  [`docs/standards/base-standards.md`](docs/standards/base-standards.md) and
43
43
  [`docs/standards/conventions.md`](docs/standards/conventions.md).
44
44
  6. **Use the skills and role agents.** `ai-specs/` is the canonical home for
45
- skills, commands, rules, and agents, mirrored to each IDE directory via
46
- symlinks.
45
+ skills, commands, rules, and agents; agents read them through the symlinks
46
+ in `.agents/` (plus an IDE folder only for an agent added explicitly).
47
47
  7. **Ask before irreversible or outward-facing actions** — destructive commands;
48
48
  writing to a real data store (DB rows or files with real user data, including
49
49
  for tests — verify against an isolated/throwaway store); publishing
@@ -82,8 +82,9 @@ its neighbors, comments that carry constraints (never ticket IDs). Full rules:
82
82
 
83
83
  ## Rule 5 — Skills and role agents are law-adjacent
84
84
 
85
- Skills, commands, rules, and agents live in `ai-specs/` (symlinked into
86
- `.claude/`, `.cursor/`, `.codex/`, `.agents/`). When a skill or role matches
85
+ Skills, commands, rules, and agents live in `ai-specs/`, read through the
86
+ symlinks in `.agents/` (an IDE folder such as `.claude/` exists only for an
87
+ agent added explicitly with `speclaw agent add`). When a skill or role matches
87
88
  the task, use it — do not improvise a parallel process.
88
89
 
89
90
  ## Rule 6 — Stop conditions
@@ -6,11 +6,9 @@ import { frontmatter, lawSlug, } from "./types.js";
6
6
  export const copilotDialect = {
7
7
  id: "copilot-instructions",
8
8
  compile(laws, ctx) {
9
- // Emit when explicitly wanted: agent id "copilot" or always if .github exists —
10
- // orchestrator passes agents; treat missing copilot as skip unless "agents" generic.
11
- const want = ctx.agents.includes("copilot") ||
12
- ctx.agents.includes("github-copilot") ||
13
- ctx.agents.includes("agents");
9
+ // Only for Copilot: the generic agent reads scoped laws from AGENTS.md
10
+ // (agentsmd dialect), so it gets no `.github/instructions/` folder.
11
+ const want = ctx.agents.includes("copilot") || ctx.agents.includes("github-copilot");
14
12
  if (!want)
15
13
  return [];
16
14
  const scoped = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
@@ -50,21 +50,6 @@ function ensureLawManifest(projectPath, report) {
50
50
  }
51
51
  return manifest;
52
52
  }
53
- /**
54
- * Write `.github/workflows/speclaw.yml` from the shipped template when the
55
- * path does not exist. Never overwrite — the user's CI is theirs.
56
- */
57
- function ensureVerifyWorkflow(projectPath, report) {
58
- const dest = path.join(projectPath, ".github", "workflows", "speclaw.yml");
59
- if (fs.existsSync(dest)) {
60
- report.skipped.push(dest);
61
- return;
62
- }
63
- const src = path.join(ASSETS, "workflows", "speclaw.yml");
64
- fs.mkdirSync(path.dirname(dest), { recursive: true });
65
- fs.copyFileSync(src, dest);
66
- report.written.push(dest);
67
- }
68
53
  /**
69
54
  * Render the foundation: walk the module's assets/, mirror its structure into
70
55
  * the project, stripping the `.template` marker (foo.template.md -> foo.md).
@@ -163,6 +148,9 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
163
148
  // it out of the module assets) plus the local .speclaw.json manifest — local,
164
149
  // per-checkout content, reconstructed by init/update, never committed.
165
150
  ensureGitignore(projectPath, "ai-specs/", "speclaw workflow content (regenerated by init/update; never commit)", report);
151
+ // The integrity lock is local tool state: it guards this checkout's rule
152
+ // files and is rebuilt by init/update, so it is not committed.
153
+ ensureGitignore(projectPath, "speclaw.lock", "speclaw local integrity lock (never commit)", report);
166
154
  for (const id of agents)
167
155
  configureAgent(projectPath, id, report); // only the chosen agents
168
156
  // Compile the declared laws into agent hooks for every hook-capable agent just
@@ -185,7 +173,6 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
185
173
  report.lockError = err.message;
186
174
  }
187
175
  }
188
- ensureVerifyWorkflow(projectPath, report);
189
176
  report.hooks = installHooks(projectPath, agents, lawManifest, report, {
190
177
  baselines: managedOpts.baselines,
191
178
  backup: managedOpts.backup,
@@ -241,7 +241,8 @@ export function sealCapability(projectPath, capability, markdown, opts = {}) {
241
241
  const now = opts.now ?? new Date().toISOString();
242
242
  const sha = headSha(projectPath);
243
243
  const specId = opts.specId ?? capability;
244
- const rows = resolveCandidates(db, projectPath, extractCandidates(markdown), specId, now, sha);
244
+ const fresh = resolveCandidates(db, projectPath, extractCandidates(markdown), specId, now, sha);
245
+ const rows = keepUnchangedStamps(readAnchorsFile(projectPath, capability), fresh);
245
246
  const dest = writeAnchorsFile(projectPath, {
246
247
  anchorsVersion: 1,
247
248
  capability,
@@ -262,6 +263,42 @@ export function sealCapability(projectPath, capability, markdown, opts = {}) {
262
263
  db.close();
263
264
  }
264
265
  }
266
+ /** Fields that identify one anchor across seals. */
267
+ function anchorKey(a) {
268
+ return [a.requirementId, a.scenarioId, a.symbolName, a.anchorKind, a.source].join("\0");
269
+ }
270
+ /** Fields whose change means the anchor was really re-sealed. */
271
+ function sealedState(a) {
272
+ return JSON.stringify([
273
+ a.specId,
274
+ a.filePath,
275
+ a.resolution,
276
+ a.contentHash,
277
+ a.rawHash,
278
+ a.normalizerVersion,
279
+ ]);
280
+ }
281
+ /**
282
+ * Keep the previous `archivedAt` / `commitSha` of every anchor whose sealed
283
+ * state did not change, so a reseal rewrites only the anchors that moved —
284
+ * an unchanged capability stays byte-identical and the drift age of an
285
+ * untouched anchor still counts from when it was really sealed.
286
+ *
287
+ * @param previous - The capability's anchors file before this seal, if any.
288
+ * @param fresh - The anchors just resolved, stamped with this seal's time and sha.
289
+ * @returns The anchors to write.
290
+ */
291
+ function keepUnchangedStamps(previous, fresh) {
292
+ if (!previous)
293
+ return fresh;
294
+ const old = new Map(previous.anchors.map((a) => [anchorKey(a), a]));
295
+ return fresh.map((a) => {
296
+ const prior = old.get(anchorKey(a));
297
+ return prior && sealedState(prior) === sealedState(a)
298
+ ? { ...a, archivedAt: prior.archivedAt, commitSha: prior.commitSha }
299
+ : a;
300
+ });
301
+ }
265
302
  /** Seal every canonical capability under lawbook/specs/ that has a spec.md. */
266
303
  export function resealAll(projectPath) {
267
304
  const specsRoot = path.join(projectPath, "lawbook", "specs");
@@ -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",
@@ -37,7 +38,7 @@ export const AGENTS = [
37
38
  id: "agents",
38
39
  label: "Generic (AGENTS.md)",
39
40
  ideDir: ".agents",
40
- linkTargets: ["skills", "agents"],
41
+ linkTargets: ["skills", "commands", "agents", "rules"],
41
42
  },
42
43
  ];
43
44
  /**
@@ -141,6 +142,24 @@ export function configureAgent(projectPath, agentId, report) {
141
142
  if (!agent)
142
143
  throw new Error(`Unknown agent "${agentId}". Known: ${AGENTS.map((a) => a.id).join(", ")}`);
143
144
  const ideRoot = path.join(projectPath, agent.ideDir);
145
+ // Everything speclaw puts in an agent folder is links into the gitignored
146
+ // ai-specs/ plus per-developer wiring: keep it out of version control. A
147
+ // folder speclaw creates is ignored whole; in a folder that already existed
148
+ // (the IDE's own, e.g. `.claude/settings.local.json`) only speclaw's entries are.
149
+ const comment = `speclaw ${agent.label} links (regenerated by init; never commit)`;
150
+ if (!fs.existsSync(ideRoot)) {
151
+ ensureGitignore(projectPath, `${agent.ideDir}/`, comment, report);
152
+ }
153
+ else {
154
+ const owned = agent.linkTargets
155
+ .filter((t) => fs.existsSync(path.join(projectPath, "ai-specs", t)))
156
+ .map((t) => `${agent.ideDir}/${t}`)
157
+ .concat(agent.ownedPaths ?? []);
158
+ if (agent.hooks && !fs.existsSync(path.join(projectPath, agent.hooks.file)))
159
+ owned.push(agent.hooks.file);
160
+ for (const entry of owned)
161
+ ensureGitignore(projectPath, entry, comment, report);
162
+ }
144
163
  for (const target of agent.linkTargets) {
145
164
  const sourceDir = path.join(projectPath, "ai-specs", target);
146
165
  if (!fs.existsSync(sourceDir))
@@ -158,13 +177,25 @@ export function configureAgent(projectPath, agentId, report) {
158
177
  writeMcpConfig(projectPath, agent.mcpFile, report);
159
178
  }
160
179
  /**
161
- * List the agents already set up in this project (their IDE dir exists).
180
+ * List the agents speclaw set up in this project: those whose IDE dir holds at
181
+ * least one speclaw link into ai-specs/. A bare IDE dir is not enough — Claude
182
+ * Code and Cursor create `.claude/` / `.cursor/` on their own, and treating
183
+ * that as "configured" made `update` populate folders nobody chose.
162
184
  *
163
185
  * @param projectPath - Project root to inspect.
164
- * @returns The ids of agents whose IDE directory is present.
186
+ * @returns The ids of agents with a speclaw link in their IDE directory.
165
187
  */
166
188
  export function detectConfiguredAgents(projectPath) {
167
- return AGENTS.filter((a) => fs.existsSync(path.join(projectPath, a.ideDir))).map((a) => a.id);
189
+ return AGENTS.filter((a) => a.linkTargets.some((t) => isAiSpecsLink(path.join(projectPath, a.ideDir, t), t))).map((a) => a.id);
190
+ }
191
+ /** True when `p` is a symlink whose target ends in `ai-specs/<target>`. */
192
+ function isAiSpecsLink(p, target) {
193
+ try {
194
+ return fs.readlinkSync(p).replace(/\\/g, "/").endsWith(`ai-specs/${target}`);
195
+ }
196
+ catch {
197
+ return false;
198
+ }
168
199
  }
169
200
  /**
170
201
  * Re-run configuration for every already-configured agent (e.g. after adding a pack).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,26 +0,0 @@
1
- # Consumer template. init/update write this only when the path is missing.
2
- # Never the untrusted PR trigger. The verify job has no secrets.
3
- name: ⚖️ speclaw
4
- on: pull_request
5
-
6
- permissions: {}
7
-
8
- jobs:
9
- verify:
10
- name: ⚖️ Verify laws
11
- runs-on: ubuntu-latest
12
- permissions:
13
- contents: read
14
- security-events: write
15
- steps:
16
- - name: 📥 Checkout
17
- uses: actions/checkout@v5
18
- with:
19
- fetch-depth: 0
20
- - name: ⚖️ Verify
21
- uses: esneiderbravo/speclaw@v2
22
- - name: 🛡️ Upload SARIF
23
- uses: github/codeql-action/upload-sarif@v4
24
- if: always()
25
- with:
26
- sarif_file: speclaw.sarif