@esneiderbravo/speclaw 0.1.15 → 0.2.1

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/README.md CHANGED
@@ -162,6 +162,19 @@ still use Compass and the lawbook engine by calling the CLI from its shell.
162
162
  <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/terminal-tree.png" width="800" alt="what speclaw writes into your project">
163
163
  </p>
164
164
 
165
+ **Committed vs. local.** Your **personalized source** is committed — `LAWS.md`,
166
+ `CLAUDE.md`, `AGENTS.md`, `docs/standards/*`, `docs/compass.md`, and the
167
+ `lawbook/` workspace. speclaw's **regenerable workflow content is local, not
168
+ committed**: only `ai-specs/` (skills, commands, rules, agent packs, and its
169
+ `.speclaw.json` manifest) is gitignored, because `init`/`update` reconstruct it
170
+ from the package — like a dependency. So **after cloning a speclaw project, run
171
+ `speclaw init` (or `speclaw update`)** to regenerate `ai-specs/` locally, which
172
+ the agent IDE symlinks point into. If a project committed `ai-specs/` before
173
+ this behavior existed, `init`/`update` print the exact `git rm -r --cached
174
+ ai-specs` command to stop tracking it (they never touch your git index
175
+ themselves). The agent directories (`.claude/`, `.cursor/`, …) are **left to
176
+ you** — commit your own skills and commands there if you want to.
177
+
165
178
  <br/>
166
179
 
167
180
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Philosophy — why "laws"?
@@ -189,9 +202,10 @@ without a re-init, splitting files by who owns them:
189
202
 
190
203
  - **Managed files** (speclaw's workflow machinery — the skills, commands, rules,
191
204
  and agent packs under `ai-specs/`) are **refreshed** to the new version, so
192
- improvements actually reach your project. If you edited one locally, `update`
193
- reports the overwrite so you can recover your copy from git; pass `--backup` to
194
- also keep a `<file>.bak`. Any `*.bak` is gitignored.
205
+ improvements actually reach your project. They live locally (gitignored, see
206
+ *What lands in your project*) and are reconstructed from the package. If you
207
+ edited one locally, `update` reports the overwrite; pass `--backup` to keep a
208
+ `<file>.bak` (itself gitignored) before it is refreshed.
195
209
  - **Personalized files** (your constitution and standards — `CLAUDE.md`,
196
210
  `AGENTS.md`, `LAWS.md`, `docs/standards/*`, `docs/compass.md`,
197
211
  `lawbook/config.yaml`) are **never auto-edited**. When a release changes their
@@ -9,6 +9,7 @@ import { loadPacks } from "../../modules/tools/packs.js";
9
9
  import { list } from "../lib/args.js";
10
10
  import { ui, c, banner, renderProgress, clearProgress } from "../lib/ui.js";
11
11
  import { checkForUpdates } from "../lib/update-check.js";
12
+ import { reportTrackedLocalContent } from "../lib/untrack.js";
12
13
  const PACK_LABELS = {
13
14
  agents: "dev-agents (backend · frontend · product)",
14
15
  };
@@ -99,6 +100,9 @@ export async function runInit(flags) {
99
100
  ui.step("Configuring agents");
100
101
  for (const id of agents)
101
102
  ui.ok(`${agentById(id).label} ${c.muted("— symlinks + MCP")}`);
103
+ // ai-specs/ is gitignored (regenerated by init/update). If a prior setup
104
+ // already committed it, tell the user how to untrack it.
105
+ reportTrackedLocalContent(cwd);
102
106
  // 2. Compass index with progress
103
107
  if (!flags["no-index"]) {
104
108
  ui.step("Indexing your code with Compass");
@@ -132,4 +136,6 @@ export async function runInit(flags) {
132
136
  ui.info(`Refresh index: ${ui.code("speclaw index")}`);
133
137
  ui.info(`Health check: ${ui.code("speclaw doctor")}`);
134
138
  ui.plain();
139
+ ui.info(`${c.muted("ai-specs/ is local (gitignored) — teammates run")} ${ui.code("speclaw init")} ${c.muted("after cloning to regenerate it.")}`);
140
+ ui.plain();
135
141
  }
@@ -40,17 +40,22 @@ export async function runSpec(flags) {
40
40
  ui.warn(`${r.change} has ${r.issues.length} issue(s):`);
41
41
  r.issues.forEach((i) => ui.info(i));
42
42
  }
43
+ if (r.warnings.length > 0) {
44
+ ui.warn(`${r.warnings.length} advisory warning(s):`);
45
+ r.warnings.forEach((w) => ui.info(w));
46
+ }
43
47
  return;
44
48
  }
45
49
  case "sync": {
46
50
  const r = specSync(cwd, req(change, "spec sync <change>"));
47
51
  ui.ok(`promoted ${r.promoted.length} spec(s)`);
48
- r.promoted.forEach((p) => ui.info(p));
52
+ r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
49
53
  return;
50
54
  }
51
55
  case "archive": {
52
56
  const r = specArchive(cwd, req(change, "spec archive <change>"), today());
53
57
  ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
58
+ r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
54
59
  return;
55
60
  }
56
61
  default:
@@ -10,6 +10,7 @@ import { detectConfiguredAgents } from "../../shared/agents.js";
10
10
  import { readManifest } from "../../shared/manifest.js";
11
11
  import { loadPacks } from "../../modules/tools/packs.js";
12
12
  import { detectProjectName } from "./init.js";
13
+ import { reportTrackedLocalContent } from "../lib/untrack.js";
13
14
  // The first migration must be tagged at the version that introduces this
14
15
  // mechanism (0.1.12): `isNewer` is strict, so an entry tagged at an already-
15
16
  // shipped version (e.g. 0.1.11) would never fire for projects already on it.
@@ -196,6 +197,10 @@ function applyProjectMigrations(cwd, backup) {
196
197
  console.log(c.cream(prompt));
197
198
  ui.plain();
198
199
  }
200
+ // This release makes ai-specs/ local (gitignored). A project that committed
201
+ // it before now still tracks it — point out how to untrack (speclaw never
202
+ // touches the git index itself).
203
+ reportTrackedLocalContent(cwd);
199
204
  ui.plain();
200
205
  ui.ok(`On ${c.cyan(pkgVersion())}. No re-init needed.`);
201
206
  }
@@ -0,0 +1,25 @@
1
+ import { ui, c } from "./ui.js";
2
+ import { listTrackedPaths } from "../../shared/git.js";
3
+ /**
4
+ * If `ai-specs/` is still tracked by git, print the exact `git rm -r --cached`
5
+ * command to untrack it. `ai-specs/` is regenerable from the package, so
6
+ * init/update gitignore it — but adding a `.gitignore` entry does not stop git
7
+ * tracking a directory it already tracks, so this is how an already-installed
8
+ * project makes that content local. It only prints — it never modifies the git
9
+ * index — and no-ops silently outside a git repository or when nothing is
10
+ * tracked. The agents' IDE directories (`.claude/`, …) are deliberately left
11
+ * alone, so a user's own skills/commands there stay committable.
12
+ *
13
+ * @param projectPath - Project root to inspect and address.
14
+ */
15
+ export function reportTrackedLocalContent(projectPath) {
16
+ const tracked = listTrackedPaths(projectPath, ["ai-specs"]);
17
+ if (!tracked.length)
18
+ return;
19
+ ui.step("Make ai-specs/ local-only");
20
+ ui.info("git still tracks ai-specs/ (regenerable). To stop tracking it (it stays on disk):");
21
+ ui.plain();
22
+ console.log(" " + c.cream(`git rm -r --cached ${tracked.join(" ")}`));
23
+ console.log(" " + c.cream('git commit -m "chore: stop tracking speclaw local content"'));
24
+ ui.plain();
25
+ }
@@ -104,6 +104,10 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
104
104
  installPack(projectPath, name, vars, report, managedOpts); // managed
105
105
  ensureGitignore(projectPath, ".speclaw/", "speclaw local code Compass (never commit)", report);
106
106
  ensureGitignore(projectPath, "*.bak", "speclaw managed-file refresh backups", report);
107
+ // ai-specs/ is regenerable from the package (installWorkflow/installPack copy
108
+ // it out of the module assets) plus the local .speclaw.json manifest — local,
109
+ // per-checkout content, reconstructed by init/update, never committed.
110
+ ensureGitignore(projectPath, "ai-specs/", "speclaw workflow content (regenerated by init/update; never commit)", report);
107
111
  for (const id of agents)
108
112
  configureAgent(projectPath, id, report); // only the chosen agents
109
113
  // Record what was installed so `speclaw update` can re-apply these packs and
@@ -15,17 +15,27 @@ If `lawbook/` is missing, run the `lawbook_init` tool once to create it.
15
15
 
16
16
  ## Step 1 — Understand the request and the code
17
17
 
18
+ - **Refresh the index first.** Run `compass_index` before reasoning about the
19
+ code — it is incremental (unchanged files are skipped by hash), so this is
20
+ cheap and guarantees your decisions rest on the current graph, not a stale one.
18
21
  - Clarify what the user wants (feature / fix / refactor) and confirm scope.
19
22
  - Use `compass_explore` and `compass_recall` (speclaw's code index) BEFORE
20
23
  grep/read to locate the real code the change touches and its blast radius.
21
- If the index is stale or missing, run `compass_index` first.
22
24
  - Read the governing standards in `docs/standards/` (architecture, backend,
23
25
  frontend, testing) so the change complies with the project's law.
24
26
 
25
- ## Step 2 — Pick a change name
27
+ ## Step 2 — Pick a change name and its capabilities
26
28
 
27
- Kebab-case, action-oriented (e.g. `add-login`, `fix-shift-overlap`). This is
28
- the folder under `lawbook/changes/`.
29
+ - **Change name:** kebab-case, action-oriented (e.g. `add-login`,
30
+ `fix-shift-overlap`). This is the folder under `lawbook/changes/`, and it is
31
+ per-feature — always distinct.
32
+ - **Capabilities:** run `lawbook_list` to see the canonical capabilities. A
33
+ capability is the living contract for an area of behavior — it is *not* the
34
+ change. When your change modifies behavior an existing capability already
35
+ governs, reuse that capability's **exact** name so `sync` updates its spec.
36
+ Introduce a new capability only as a deliberate choice for a genuinely distinct
37
+ area of behavior — never as a near-duplicate (`transfer` next to an existing
38
+ `transfers`) of one that already exists.
29
39
 
30
40
  ## Step 3 — Write the artifacts
31
41
 
@@ -34,7 +44,12 @@ Create under `lawbook/changes/<name>/`:
34
44
  - **proposal.md** — the why, the what, non-goals, and whether migrations are
35
45
  needed. Reference the team's tracker ticket if there is one.
36
46
  - **specs/<capability>/spec.md** — the delta spec for each affected capability.
37
- Use normative language and testable scenarios:
47
+ `sync` promotes this by overwriting the whole canonical file, so the delta must
48
+ carry the capability's **full** intended spec. When you are updating an existing
49
+ capability, **start from the current `lawbook/specs/<capability>/spec.md`** and
50
+ edit on top of it, so its existing requirements are carried forward — do not
51
+ author it from scratch, or promotion will silently drop them. Use normative
52
+ language and testable scenarios:
38
53
  ```markdown
39
54
  # <Capability>
40
55
 
@@ -63,7 +78,11 @@ Create under `lawbook/changes/<name>/`:
63
78
 
64
79
  Run the `lawbook_validate` tool for the change and fix every issue it reports
65
80
  (missing artifacts, non-normative specs, missing scenarios) before handing off
66
- to implementation.
81
+ to implementation. Read its advisory **warnings** too: a near-duplicate
82
+ capability name usually means you should reuse the existing capability's exact
83
+ name, and a dropped-requirement warning means the delta should start from the
84
+ canonical. Warnings do not block, but resolve them unless the divergence is
85
+ intentional.
67
86
 
68
87
  ## Step 5 — Hand off
69
88
 
@@ -11,9 +11,12 @@ understanding and a recommended direction.
11
11
 
12
12
  ## How to explore
13
13
 
14
+ - **Refresh the index first.** Run `compass_index` before investigating — it is
15
+ incremental (unchanged files skipped by hash), so it is cheap and keeps your
16
+ reasoning on the current graph rather than a stale one.
14
17
  - **Understand the code first.** Use `compass_recall` to find relevant code by
15
18
  meaning and `compass_explore` to read a symbol's source plus its callers and
16
- callees — before grep/read. Run `compass_index` if the graph is missing.
19
+ callees — before grep/read.
17
20
  - **Ask sharp questions** to surface hidden assumptions, constraints, and edge
18
21
  cases. Confirm scope and non-goals.
19
22
  - **Check the law.** Read the relevant `docs/standards/` so any direction you
@@ -35,7 +35,11 @@ specs that become canonical describe reality, not just the original draft.
35
35
 
36
36
  4. Run the `lawbook_sync` tool for the change. It copies each
37
37
  `lawbook/changes/<name>/specs/<capability>/spec.md` over the canonical
38
- `lawbook/specs/<capability>/spec.md` and reports what it promoted.
38
+ `lawbook/specs/<capability>/spec.md` and reports what it promoted, flagging
39
+ each as **created** (new capability) or **updated** (overwrote an existing
40
+ one). A capability you expected to update showing up as *created* means the
41
+ delta forked a near-duplicate — fix the name before promoting.
39
42
 
40
43
  5. Report to the user what you reconciled (or that nothing drifted) and the
41
- promoted files. The change stays active — `archive` it when it's fully done.
44
+ promoted files (created vs updated). The change stays active — `archive` it
45
+ when it's fully done.
@@ -84,6 +84,47 @@ export function specInit(projectPath) {
84
84
  ensure("README.md", README_MD);
85
85
  return { created, alreadyExisted };
86
86
  }
87
+ /** Names of the canonical capabilities (directories under lawbook/specs/). */
88
+ function canonicalCapabilities(root) {
89
+ const specsDir = path.join(root, "specs");
90
+ if (!fs.existsSync(specsDir))
91
+ return [];
92
+ return fs
93
+ .readdirSync(specsDir, { withFileTypes: true })
94
+ .filter((e) => e.isDirectory())
95
+ .map((e) => e.name);
96
+ }
97
+ /** The "### Requirement:" titles declared in a spec markdown document. */
98
+ function requirementHeaders(markdown) {
99
+ const out = [];
100
+ for (const m of markdown.matchAll(/^###\s+Requirement:\s*(.+?)\s*$/gm))
101
+ out.push(m[1]);
102
+ return out;
103
+ }
104
+ /** Levenshtein edit distance between two strings (small, dependency-free). */
105
+ function editDistance(a, b) {
106
+ const rows = a.length + 1;
107
+ const cols = b.length + 1;
108
+ let prev = Array.from({ length: cols }, (_, j) => j);
109
+ for (let i = 1; i < rows; i++) {
110
+ const curr = [i];
111
+ for (let j = 1; j < cols; j++) {
112
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
113
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
114
+ }
115
+ prev = curr;
116
+ }
117
+ return prev[cols - 1];
118
+ }
119
+ /**
120
+ * The existing canonical capability that a name is a near-match of (edit
121
+ * distance ≤ 2 and not equal), or undefined when the name is exact or unrelated.
122
+ */
123
+ function nearMatchCapability(name, capabilities) {
124
+ if (capabilities.includes(name))
125
+ return undefined;
126
+ return capabilities.find((c) => editDistance(name, c) <= 2);
127
+ }
87
128
  /** Recursively collect every .md file under a change's specs/ directory. */
88
129
  function deltaSpecFiles(changeDir) {
89
130
  const specsDir = path.join(changeDir, "specs");
@@ -120,6 +161,7 @@ export function specValidate(projectPath, change) {
120
161
  change,
121
162
  valid: false,
122
163
  issues: [`change "${change}" not found under lawbook/changes/`],
164
+ warnings: [],
123
165
  deltaSpecs: [],
124
166
  };
125
167
  }
@@ -131,6 +173,10 @@ export function specValidate(projectPath, change) {
131
173
  const deltas = deltaSpecFiles(changeDir);
132
174
  if (deltas.length === 0)
133
175
  issues.push("no delta specs under specs/ (a change should specify what it changes)");
176
+ const root = specRoot(projectPath);
177
+ const changeSpecs = path.join(changeDir, "specs");
178
+ const capabilities = canonicalCapabilities(root);
179
+ const warnings = [];
134
180
  for (const file of deltas) {
135
181
  const rel = path.relative(changeDir, file);
136
182
  const content = fs.readFileSync(file, "utf8");
@@ -143,21 +189,46 @@ export function specValidate(projectPath, change) {
143
189
  if (!/^###\s+Requirement:/m.test(content)) {
144
190
  issues.push(`${rel}: no "### Requirement:" header`);
145
191
  }
192
+ // Advisory divergence checks against the canonical specs.
193
+ const relFromSpecs = path.relative(changeSpecs, file);
194
+ const capability = relFromSpecs.split(path.sep)[0];
195
+ const nearMatch = nearMatchCapability(capability, capabilities);
196
+ if (nearMatch) {
197
+ warnings.push(`${rel}: capability "${capability}" is not canonical but resembles ` +
198
+ `"${nearMatch}" — did you mean to update it? Reuse the exact name to ` +
199
+ `update the existing spec instead of forking a near-duplicate.`);
200
+ }
201
+ else if (capabilities.includes(capability)) {
202
+ const canonicalFile = path.join(root, "specs", relFromSpecs);
203
+ if (fs.existsSync(canonicalFile)) {
204
+ const deltaReqs = new Set(requirementHeaders(content));
205
+ const dropped = requirementHeaders(fs.readFileSync(canonicalFile, "utf8")).filter((r) => !deltaReqs.has(r));
206
+ if (dropped.length > 0) {
207
+ warnings.push(`${rel}: delta drops ${dropped.length} requirement(s) present in the ` +
208
+ `canonical "${capability}" spec (${dropped.join("; ")}) — start the ` +
209
+ `delta from the canonical unless the removal is intentional.`);
210
+ }
211
+ }
212
+ }
146
213
  }
147
214
  return {
148
215
  change,
149
216
  valid: issues.length === 0,
150
217
  issues,
218
+ warnings,
151
219
  deltaSpecs: deltas.map((f) => path.relative(projectPath, f)),
152
220
  };
153
221
  }
154
222
  /**
155
223
  * Promote a change's delta specs into the canonical specs/, overwriting the
156
- * file for each affected capability.
224
+ * file for each affected capability. Each promoted path is also classified as
225
+ * `created` (no canonical file existed) or `updated` (one was overwritten) so an
226
+ * unintended new capability is visible in the result — a pure path check that
227
+ * keeps this a deterministic, code-blind copy.
157
228
  *
158
229
  * @param projectPath - Absolute path to the project root.
159
230
  * @param change - Change name (folder under lawbook/changes/).
160
- * @returns The change name and the list of promoted spec paths.
231
+ * @returns The change name, the promoted spec paths, and the created/updated split.
161
232
  * @throws If the change directory does not exist.
162
233
  */
163
234
  export function specSync(projectPath, change) {
@@ -167,8 +238,10 @@ export function specSync(projectPath, change) {
167
238
  throw new Error(`change "${change}" not found`);
168
239
  const changeSpecs = path.join(changeDir, "specs");
169
240
  const promoted = [];
241
+ const created = [];
242
+ const updated = [];
170
243
  if (!fs.existsSync(changeSpecs))
171
- return { change, promoted };
244
+ return { change, promoted, created, updated };
172
245
  const walk = (dir) => {
173
246
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
174
247
  const full = path.join(dir, e.name);
@@ -177,14 +250,17 @@ export function specSync(projectPath, change) {
177
250
  else if (e.name.endsWith(".md")) {
178
251
  const rel = path.relative(changeSpecs, full);
179
252
  const dest = path.join(root, "specs", rel);
253
+ const existed = fs.existsSync(dest);
180
254
  fs.mkdirSync(path.dirname(dest), { recursive: true });
181
255
  fs.copyFileSync(full, dest);
182
- promoted.push(path.join("lawbook/specs", rel));
256
+ const promotedPath = path.join("lawbook/specs", rel);
257
+ promoted.push(promotedPath);
258
+ (existed ? updated : created).push(promotedPath);
183
259
  }
184
260
  }
185
261
  };
186
262
  walk(changeSpecs);
187
- return { change, promoted };
263
+ return { change, promoted, created, updated };
188
264
  }
189
265
  /**
190
266
  * Deterministic completeness checks that gate archiving a change. Returns the
@@ -256,13 +332,13 @@ export function specArchive(projectPath, change, date) {
256
332
  if (blockers.length > 0) {
257
333
  throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
258
334
  }
259
- const { promoted } = specSync(projectPath, change);
335
+ const { promoted, created, updated } = specSync(projectPath, change);
260
336
  const archiveDir = path.join(root, "changes", "archive", `${date}-${change}`);
261
337
  fs.mkdirSync(path.dirname(archiveDir), { recursive: true });
262
338
  if (fs.existsSync(archiveDir))
263
339
  throw new Error(`archive target already exists: ${archiveDir}`);
264
340
  fs.renameSync(changeDir, archiveDir);
265
- return { change, promoted, archivedTo: path.relative(projectPath, archiveDir) };
341
+ return { change, promoted, created, updated, archivedTo: path.relative(projectPath, archiveDir) };
266
342
  }
267
343
  /**
268
344
  * List the spec workspace: active changes, archived changes, and canonical
@@ -29,14 +29,14 @@ export function registerSpec(server) {
29
29
  inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
30
30
  }, async ({ projectPath }) => text(specList(projectPath)));
31
31
  server.registerTool("lawbook_validate", {
32
- description: "Validate a change's artifacts: proposal.md and tasks.md present, and delta specs use normative language (SHALL/MUST), '### Requirement:' headers, and '#### Scenario:' acceptance criteria. Returns the issues to fix. Used by the draft/build commands before proceeding.",
32
+ description: "Validate a change's artifacts: proposal.md and tasks.md present, and delta specs use normative language (SHALL/MUST), '### Requirement:' headers, and '#### Scenario:' acceptance criteria. Returns the blocking issues to fix plus advisory (non-blocking) warnings — a capability name that resembles an existing canonical one, or requirements dropped versus the canonical. Used by the draft/build commands before proceeding.",
33
33
  inputSchema: {
34
34
  projectPath: z.string().describe("Absolute path to the project"),
35
35
  change: z.string().describe("Change name (folder under lawbook/changes/)"),
36
36
  },
37
37
  }, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
38
38
  server.registerTool("lawbook_sync", {
39
- description: "Promote a change's delta specs into the canonical lawbook/specs/ (per capability), without archiving. Backs the `sync` command.",
39
+ description: "Promote a change's delta specs into the canonical lawbook/specs/ (per capability), without archiving. Reports each promoted spec as created (new capability) or updated (overwrote an existing one). Backs the `sync` command.",
40
40
  inputSchema: {
41
41
  projectPath: z.string().describe("Absolute path to the project"),
42
42
  change: z.string().describe("Change name (folder under lawbook/changes/)"),
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * True when `projectPath` is inside a git working tree.
4
+ *
5
+ * Best-effort: shells `git rev-parse --is-inside-work-tree` and treats any
6
+ * failure (git not installed, not a repository) as "not a repo" rather than
7
+ * throwing — callers use this only to decide whether to attempt further git
8
+ * queries.
9
+ *
10
+ * @param projectPath - Directory to test.
11
+ * @returns `true` only when git reports the path is inside a work tree.
12
+ */
13
+ export function isGitRepo(projectPath) {
14
+ const res = spawnSync("git", ["-C", projectPath, "rev-parse", "--is-inside-work-tree"], {
15
+ encoding: "utf8",
16
+ });
17
+ return res.status === 0 && res.stdout.trim() === "true";
18
+ }
19
+ /**
20
+ * Of `candidates` (project-relative paths), the subset git currently tracks.
21
+ *
22
+ * Adding a path to `.gitignore` does not stop git tracking a file it already
23
+ * tracks; this reports which speclaw paths are still tracked so a command can
24
+ * tell the user how to untrack them. Returns an empty array when `projectPath`
25
+ * is not a git repository (or git is unavailable).
26
+ *
27
+ * @param projectPath - Project root to query.
28
+ * @param candidates - Project-relative paths (files, directories, or symlinks).
29
+ * @returns The candidates for which `git ls-files` reports at least one tracked
30
+ * entry, in the order given.
31
+ */
32
+ export function listTrackedPaths(projectPath, candidates) {
33
+ if (!isGitRepo(projectPath))
34
+ return [];
35
+ return candidates.filter((rel) => {
36
+ const res = spawnSync("git", ["-C", projectPath, "ls-files", "--", rel], { encoding: "utf8" });
37
+ return res.status === 0 && res.stdout.trim().length > 0;
38
+ });
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.1.15",
3
+ "version": "0.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },