@luizsantiago/spec-guardrails 3.6.0 → 3.8.0

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
@@ -5,207 +5,136 @@
5
5
 
6
6
  **Keep AI coding agents honest — specify the work, prove each step, verify independently.**
7
7
 
8
- | | |
9
- | --- | --- |
10
- | **Problem** | Agents ship “looks good” with thin specs, missing evidence, and the same context that wrote the code declaring victory. |
11
- | **Solution** | One kit, two deliberate modes: **Process** (Node only) for a flexible spec-driven workflow; **Brakes** (Node + Python) for the **full product** — structural gates that exit non-zero when paperwork or evidence is missing. You approve specs/tasks in both. |
12
- | **Result** | Traceable `.specs/` memory, fewer fake finishes, cheaper turns (~70% less skill text on planning). Choose Process for light ceremony; add Python when you want the [Guarantees matrix](#guarantees-matrix) enforced automatically. |
13
-
14
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.6.x**
8
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.8.x**
15
9
 
16
10
  ---
17
11
 
18
- ## Install
19
-
20
- ```bash
21
- npx @luizsantiago/spec-guardrails install
22
- ```
12
+ ## What is Spec Guardrails?
23
13
 
24
- ### What you need
14
+ **Spec Guardrails** is a **process kit** for AI-assisted software development. It does not replace your app, framework, or tests. It gives your agent a repeatable way to work:
25
15
 
26
- | Requirement | Role |
27
- | --- | --- |
28
- | **Node.js 18+** | Required runs the CLI and `install` |
29
- | **Python 3.10+** | Activates **Brakes mode** — the **full** kit with Python structural gates (`validate-spec`, `validate-tasks`, …). Gates stay Python by design. Without Python you stay in **Process mode**: same phases and checklists, no exit-code enforcement. Run [`doctor`](#install) to see which mode you are in |
16
+ 1. **Write down the goal** before coding (`spec.md`)
17
+ 2. **Break work into small jobs** (`tasks.md`)
18
+ 3. **Implement in waves** with real checks
19
+ 4. **Verify with fresh eyes** proof, not “trust me”
30
20
 
31
- ### What install does
21
+ Everything important lives in **`.specs/`** in your repo so the project remembers decisions across chats and teammates.
32
22
 
33
- | Lands in your project | Purpose |
34
- | --- | --- |
35
- | `.cursor/skills/` + `.claude/skills/` + `.github/skills/` + `.codex/skills/` | Hub, phase references, sister skills (**shipped adapters** — same content, product-specific paths) |
36
- | `.specs/guardrails/scripts/` | Python gate scripts (Brakes mode) |
37
- | `.specs/STATE.md`, `.specs/features/`, … | Project memory (any agent) |
38
- | `.cursor/rules/engineering-baseline.mdc` | Always-on Cursor rule |
23
+ **You** approve specs and tasks. **The agent** follows phase guides and runs checks. **Gates** (optional Python scripts) can **stop** the workflow when paperwork or evidence is missing.
39
24
 
40
- **Agent environments:** the **core** (`.specs/`, CLI, hub, Python gates) works with any AI agent. **Install** ships adapters for **Cursor, Claude Code, GitHub Copilot, and OpenAI Codex** (plus root `AGENTS.md`). See [Architecture](docs/guide/Architecture.md).
41
-
42
- Re-run `install` anytime to refresh skills; your `.specs/` decisions and `STATE.md` are kept.
43
-
44
- ### Governance focus (3.2+)
45
-
46
- Spec Guardrails is a **single product**: governance, evidence, verification, and controlled execution for agentic software development — not a bundle of companion packages.
47
-
48
- | Capability | What it does |
49
- | --- | --- |
50
- | **Artifact gates** | Structural quality for spec, tasks, and cross-artifact consistency — with **blocking / warning / info** severity |
51
- | **Parallel waves** | `loop-plan` computes safe parallel groups; `workspace-prepare` isolates tasks in git worktrees |
52
- | **Execution policy** | Budgets, path scope, and escalation rules in `.specs/config.yaml` — consult via `execution-policy` |
53
- | **Independent verify** | Fresh-context verification with evidence-or-zero (`validate-state`) |
54
-
55
- | Need | Command |
56
- | --- | --- |
57
- | First time / upgrade | `install` |
58
- | Existing codebase | `project-init` (optional) |
59
- | Something looks wrong | `doctor` |
60
- | Full CLI list | `--help` |
25
+ > **Plain-language guide:** [Overview](docs/guide/Overview.md) · [How it works](docs/guide/How-it-works.md) · [Quick start](docs/guide/Quick-start.md)
61
26
 
62
27
  ---
63
28
 
64
- ## How it works in one screen
29
+ ## What it is not
65
30
 
66
- Four ideas stack full explanation: **[Concepts](docs/guide/concepts.md)**
67
-
68
- | Idea | What it is | What it does |
69
- | --- | --- | --- |
70
- | **Spec-driven** | Written plan before code | `spec.md` + `tasks.md`; evidence before “done” |
71
- | **Brakes / Gates** | Structural stop-gates | Python scripts exit non-zero when paperwork or evidence is missing |
72
- | **Loop** | Execute in waves | `loop-plan` picks the next jobs; sub-agents when files don’t overlap |
73
- | **Graph** | Parallel task map | `task-graph.md` — safe parallelism without file collisions |
74
- | **Memory** | Persistent project state | `.specs/` — specs, decisions, and handoff survive across chats |
75
-
76
- **You** approve specs and tasks. **The agent** runs gates and implements. **Gates** exit non-zero when paperwork or evidence is missing.
77
-
78
- Plain-language tour: [Home](docs/guide/Home.md) · [How it works](docs/guide/How-it-works.md) · [Quick start](docs/guide/Quick-start.md)
31
+ - Not a code generator or a new framework
32
+ - Not a vector database or “AI memory” that reads your whole codebase
33
+ - Not automatic push/merge/deploy you stay in control of git tiers
34
+ - Not a replacement for code review or product judgment
79
35
 
80
36
  ---
81
37
 
82
- ## Operating modes
83
-
84
- Two modes, one package — pick how much rigor you want:
85
-
86
- | Mode | Runtime | What you get | Best for |
87
- | --- | --- | --- | --- |
88
- | **Process** | Node.js 18+ | Spec-driven workflow, `.specs/` memory, progressive loading, independent `/verify` | Flexible ceremony, exploration, teams that enforce by review |
89
- | **Brakes** | Node + **Python 3.10+** | Everything in Process **plus** Python gates from the [Guarantees matrix](#guarantees-matrix) — exit ≠ 0 → stop and fix | The **full Spec Guardrails** — traceability, evidence, and structural guarantees enforced automatically |
90
-
91
- **Gates stay Python.** That is the product: Brakes mode is the complete version with automated enforcement. Process mode is the same loop without exit-code brakes — intentional, not incomplete.
92
-
93
- Install Python when you want gates to fire; run `doctor` to confirm Brakes are available.
38
+ ## Install (once per project)
94
39
 
95
- ---
40
+ ```bash
41
+ npx @luizsantiago/spec-guardrails install
42
+ ```
96
43
 
97
- ## Guarantees matrix
44
+ | Requirement | Purpose |
45
+ | --- | --- |
46
+ | **Node.js 18+** | Required — CLI and install |
47
+ | **Python 3.10+** | Optional — enables **Brakes mode** (automatic gates). Without Python you still get the full workflow in **Process mode** |
98
48
 
99
- **Guarantees are the product.** Commands are implementation.
49
+ Re-run `install` after upgrading the package. Your `.specs/` notes and `STATE.md` are kept.
100
50
 
101
- | Guarantee | Mechanism | Mode | Enforcement |
102
- | --- | --- | --- | --- |
103
- | Intent exists before code | `validate-spec` | Brakes | Hard gate |
104
- | Tasks derive from requirements | `analyze-artifacts` | Brakes | Hard gate |
105
- | Task shape and graph when needed | `validate-tasks` | Brakes | Hard gate |
106
- | Requirements stay traceable | `validate-traceability` | Brakes | Hard gate |
107
- | Quick evidence is complete | `validate-quick` | Brakes | Hard gate |
108
- | Dependencies respected in Execute | `loop-plan` | Brakes | Hard gate |
109
- | Parallel work is file-safe | `task-graph.md` + `validate-tasks` | Process + Brakes | Artifact + gate |
110
- | Completion cites evidence | `validate-state` | Brakes | Hard gate |
111
- | Commits follow policy | `check-commit` | Brakes | Hard gate |
112
- | Lessons grounded after FAIL | `lessons` | Brakes | Hard gate |
113
- | Verification is independent | `/verify` + `validate.md` | Process | Phase skill |
114
- | Knowledge survives chats | `.specs/` + `archive-feature` | Process | Install + CLI |
51
+ Check readiness: `npx @luizsantiago/spec-guardrails doctor`
115
52
 
116
- Full matrix, limits, and phase diagram **[Guarantees matrix](docs/guide/Guarantees-matrix.md)** · [Architecture](docs/guide/Architecture.md) (Core + adapters)
53
+ Works with **Cursor, Claude Code, GitHub Copilot, OpenAI Codex**, and other agents via root `AGENTS.md`. See [Platform parity](docs/guide/Platform-parity.md).
117
54
 
118
55
  ---
119
56
 
120
- ## Token cost
57
+ ## How you use it day to day
121
58
 
122
- Progressive loading is the main cost win: **one working set per turn**, not the entire playbook.
59
+ You talk to the agent in **chat**, not the terminal. These are **agent commands** (phrases the agent understands):
123
60
 
124
- | Profile | Est. tokens | When |
125
- | ---: | ---: | --- |
126
- | Naive full dump (don’t) | ~31k | Loading every skill + reference every message |
127
- | Specify turn | ~9k | `/specify` hub + `specify.md` + standards |
128
- | Tasks turn | ~10k | `/tasks` hub + `tasks.md` + task-graph skill |
129
- | Execute `/loop` (one wave) | ~4k | One implement wave (inline or parallel) |
130
- | Verify turn | ~6k | Independent reviewer stack |
61
+ | Command | When to use |
62
+ | --- | --- |
63
+ | `/specify` | Start a real feature written requirements first |
64
+ | `/tasks` | Shopping list of jobs after you approve the spec |
65
+ | `/loop` | Implementone wave at a time |
66
+ | `/verify` | Proof after the last job use a **fresh** chat when possible |
67
+ | `/quick` | Tiny fix only (≤3 files, no big design decisions) |
131
68
 
132
- Savings vs full dump: **~72%** (Specify), **~86%** (Execute). Numbers from `lib/token-cost.js`; CI guardrails in `test/test_token_cost.test.js`. Order-of-magnitude only — not a billing API.
69
+ **Typical path:** `/specify` approve `/tasks` approve `/loop` `/verify` `/archive`
133
70
 
134
- More: [Token efficiency](docs/guide/Token-efficiency.md)
71
+ You do **not** need to memorize CLI commands. The agent runs gates and helpers for you when **Brakes mode** is available.
135
72
 
136
- ---
73
+ **Full command reference:** [Agent commands](docs/guide/agent-commands.md)
137
74
 
138
- ## Complexity tiers (how work flows)
75
+ ---
139
76
 
140
- The hub **Complexity Router** picks how much ceremony a feature needs — Quick, Simple, Medium, Complex, or Parallel. It is **not** a separate product feature; it is how the agent decides which phases to run.
77
+ ## Two modes (same product)
141
78
 
142
- | Tier | Typical scope | Path |
79
+ | Mode | You need | What changes |
143
80
  | --- | --- | --- |
144
- | **Quick** | ≤3 files, no new deps | `/quick` verify commit |
145
- | **Simple** | Small localized change | `/specify` `/loop` `/verify` |
146
- | **Medium** | New feature, <10 tasks | `/specify` → `/tasks` → `/loop` → `/verify` → `/archive` |
147
- | **Complex** | APIs, architecture, infra | + `/discuss`, `/plan`, optional security/QA on verify |
148
- | **Parallel** | Splittable work | Above + `/task-graph` when 3+ tasks |
149
-
150
- Rules and examples: [Concepts → Complexity tiers](docs/guide/concepts.md#complexity-tiers--how-the-agent-chooses-depth)
151
-
152
- ---
81
+ | **Process** | Node only | Full workflow + `.specs/` memory + phase skills |
82
+ | **Brakes** | Node + Python | Same + scripts that **exit non-zero** when specs, tasks, or evidence are incomplete |
153
83
 
154
- ## Hub and skills (summary)
84
+ Both are intentional. Add Python when you want automatic stop signs, not just checklists.
155
85
 
156
- Install copies a **hub** (`agent-architecture.md`), **phase references** (`references/*.md`), and **sister skills** (security, task-graph, …). The agent loads **one phase file at a time**.
86
+ Details: [FAQ Process vs Brakes](docs/guide/FAQ.md#process-vs-brakes)
157
87
 
158
- | Load order | Layer | Role | Examples |
159
- | ---: | --- | --- | --- |
160
- | 1 | **Hub** | Contract, complexity router, gate schedule | `agent-architecture.md` |
161
- | 2 | **Reference** | One phase procedure per turn | `specify.md`, `implement.md`, `validate.md` |
162
- | 3 | **Sister** (optional) | Cross-cutting depth, on demand | `engineering-standards.md`, `task-graph-engineering.md` |
163
- | 4 | **Gate** | Automatic check at the boundary | `validate-spec`, `loop-plan`, `check-commit` |
88
+ ---
164
89
 
165
- Conditional sisters (`appsec.md`, `qa-strategy.md`, …) load **one at a time** on Verify when risk warrants it.
90
+ ## What lands in your repo
166
91
 
167
- Full map: **[Skills and hub](docs/guide/skills-and-hub.md)**
92
+ | Path | Role |
93
+ | --- | --- |
94
+ | `.cursor/skills/` (and Claude, Copilot, Codex trees) | Instructions the agent reads per phase |
95
+ | `.specs/STATE.md` | Where you are — active feature, next step |
96
+ | `.specs/features/NNN-slug/` | Spec, tasks, validation for each feature |
97
+ | `.specs/guardrails/scripts/` | Python gates (Brakes mode) |
98
+ | `.specs/config.yaml` | Optional project rules and policy |
168
99
 
169
100
  ---
170
101
 
171
- ## Gates (summary)
102
+ ## Optional capabilities (use when you need them)
172
103
 
173
- Commands implement the guarantees above. Scripts in `.specs/guardrails/scripts/`. **Exit 0 stop and fix.**
104
+ Most projects start with Specify Tasks Loop Verify only. Turn these on when the work warrants it:
174
105
 
175
- | When | Gate | What it blocks |
106
+ | Capability | Plain purpose | Learn more |
176
107
  | --- | --- | --- |
177
- | Before approving spec | `validate-spec` | Incomplete or untestable spec |
178
- | Before approving tasks | `analyze-artifacts` | Spec tasks drift |
179
- | Before approving tasks | `validate-tasks` | Bad tasks; missing graph when 3+ tasks |
180
- | After tasks / with validation | `validate-traceability` | REQ missing from tasks or coverage lines |
181
- | End of `/quick` | `validate-quick` | Incomplete Quick TASK/SUMMARY; >3 files; sensitive paths |
182
- | Each `/loop` wave | `loop-plan` | Blocked dependencies; shows parallel groups |
183
- | Each commit | `check-commit` | Non-Conventional commit message |
184
- | Before “done” | `validate-state` | Fake PASS without test evidence |
185
- | After Verify FAIL | `lessons` | Ungrounded “lessons learned” |
186
- | After Verify PASS | `archive-feature` | (CLI) folds feature into domain memory |
187
-
188
- Full reference: **[Gates](docs/guide/gates.md)** · [Guarantees matrix](docs/guide/Guarantees-matrix.md) · [Gates and guarantees](docs/guide/Gates-and-guarantees.md)
108
+ | **Memory search** | Find text in past specs and validations | [Memory guide](docs/guide/Memory.md) |
109
+ | **Context guards** | Check scope before edit or “done” | [Agent commands](docs/guide/agent-commands.md) · **Cursor:** auto via hooks (3.8+) |
110
+ | **Execution policy** | Limit paths, retries, dangerous ops | [Overview Safety](docs/guide/Overview.md#safety-and-limits) |
111
+ | **Solution exploration** | Compare two+ implementations before committing | [Overview Exploration](docs/guide/Overview.md#optional-exploration-mode) |
112
+ | **Semantic retrieval** | Search by meaning (needs OpenAI or Ollama) | [Memory → Semantic](docs/guide/Memory.md#semantic-search-optional) |
113
+
114
+ **Default:** semantic search is **off**. FTS + graph search work without any API.
189
115
 
190
116
  ---
191
117
 
192
118
  ## Documentation
193
119
 
194
- | Doc | For |
120
+ | Start here | Best for |
195
121
  | --- | --- |
196
- | [Guarantees matrix](docs/guide/Guarantees-matrix.md) | Product promises mechanisms |
197
- | [Architecture](docs/guide/Architecture.md) | Core vs platform adapters |
198
- | [Agent commands](docs/guide/agent-commands.md) | Every `/specify`, `/loop`, `/verify`, … — purpose, when, examples |
122
+ | **[Overview](docs/guide/Overview.md)** | Complete simple picture workflow, layers, team use |
199
123
  | [Quick start](docs/guide/Quick-start.md) | First ten minutes |
200
- | [Concepts](docs/guide/concepts.md) | Spec-driven + guardrails + loop + graph |
201
- | [Skills and hub](docs/guide/skills-and-hub.md) | What each skill file does |
202
- | [Gates](docs/guide/gates.md) | How each gate works |
203
- | [Platform parity](docs/guide/Platform-parity.md) | Shipped adapters (Cursor, Claude, Copilot, Codex) — core works with any agent |
204
- | [Restart PRD seed](docs/guide/Restart-prd-seed.md) | Clean-project PRD template for a single-package restart |
124
+ | [How it works](docs/guide/How-it-works.md) | Story from idea to archive |
125
+ | [Memory](docs/guide/Memory.md) | `.specs/` memory and search when and how much |
205
126
  | [FAQ](docs/guide/FAQ.md) | Common questions |
206
- | [Changelog](docs/CHANGELOG.md) | Full version history |
207
127
 
208
- Start after install: [Quick start](docs/guide/Quick-start.md) · [Agent commands](docs/guide/agent-commands.md)
128
+ | Go deeper | Best for |
129
+ | --- | --- |
130
+ | [Agent commands](docs/guide/agent-commands.md) | Every `/specify`, `/loop`, CLI helper |
131
+ | [Guarantees matrix](docs/guide/Guarantees-matrix.md) | Product promises → mechanisms |
132
+ | [Gates](docs/guide/gates.md) | What each gate checks |
133
+ | [Architecture](docs/guide/Architecture.md) | Core vs platform adapters |
134
+ | [Concepts](docs/guide/concepts.md) | Spec-driven, loop, graph, tiers |
135
+ | [Changelog](docs/CHANGELOG.md) | Version history |
136
+
137
+ Doc index: [docs/guide/README.md](docs/guide/README.md)
209
138
 
210
139
  ---
211
140
 
@@ -215,59 +144,12 @@ Start after install: [Quick start](docs/guide/Quick-start.md) · [Agent commands
215
144
  npx @luizsantiago/spec-guardrails install
216
145
  ```
217
146
 
218
- | Version | What you gain |
219
- | --- | --- |
220
- | **3.6.x** | Hybrid retrieval (`memory-retrieve`); chunk FTS; optional semantic embed |
221
- | **3.5.x** | Solution exploration (`solution-explore`) — compare candidates from approved spec |
222
- | **3.4.x** | Contextual guards (`context-guard`); FTS memory search (`memory-search`) |
223
- | **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
224
- | **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
225
- | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
226
- | **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
227
- | **2.2.x** | Seatbelt-era paths & markers; `doctor` Execute hints; docs split from README |
228
- | **2.1.x** | `loop-plan` + parallel `/loop` waves |
229
- | **2.0.x** | Package rename → `@luizsantiago/spec-seatbelt` (superseded by 3.0) |
230
- | **1.1.x** | `project-init` for brownfield repos |
231
- | **0.9.x** | `archive-feature` + domain memory merge |
232
-
233
- Full history: [CHANGELOG](docs/CHANGELOG.md) · [Releases](https://github.com/luizssantiago92/spec-guardrails/releases) · [Stability policy](docs/guide/Stability-policy.md)
234
-
235
- Lineage: `agentic-harness` → `spec-seatbelt` → **`spec-guardrails` (final)**. Run `install` once after switching. See [Migration](docs/guide/Migration.md).
147
+ See [CHANGELOG](docs/CHANGELOG.md) for release notes.
236
148
 
237
149
  ---
238
150
 
239
- ## Contributing
240
-
241
- See [CONTRIBUTING.md](CONTRIBUTING.md) — tests, gate freeze policy, local `npm run guardrails -- install`.
242
-
243
- ---
244
-
245
- ## Credits
246
-
247
- Spec Guardrails adapts open ideas; we did not invent spec-driven phases, loop design, or task-graph rules.
248
-
249
- ### Core lineage
250
-
251
- | Source | License | How we use it |
252
- | --- | --- | --- |
253
- | [tlc-spec-driven](https://github.com/tech-leads-club/agent-skills/tree/main/packages/skills-catalog/skills/(development)/tlc-spec-driven) | CC-BY-4.0 | Phase model, `.specs/` memory, gate lineage |
254
- | [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills) | MIT | Discuss patterns, definition-of-done |
255
- | [graph-engineering](https://github.com/codejunkie99/graph-engineering) | MIT | Task-graph topology, stop rules, parallel merge |
256
-
257
- ### Loop & ecosystem
258
-
259
- | Source | License | How we use it |
260
- | --- | --- | --- |
261
- | [loop-engineering](https://github.com/cobusgreyling/loop-engineering) | MIT | Operational loop patterns; `doctor` score metaphor |
262
- | [Addy Osmani — Loop engineering](https://addyosmani.com/blog/loop-engineering/) | — | Essay lineage |
263
- | [awesome-harness-engineering](https://github.com/ai-boost/awesome-harness-engineering) | CC0 | Ecosystem taxonomy |
264
-
265
- ### Adjacent (not vendored)
266
-
267
- [DeepCode](https://github.com/HKUDS/DeepCode) · [RepoGraph](https://github.com/ozyyshr/RepoGraph)
268
-
269
- Extended attribution: [docs/guide/credits.md](docs/guide/credits.md)
270
-
271
- ## License
151
+ ## Contributing · Credits · License
272
152
 
273
- MIT
153
+ - [CONTRIBUTING.md](CONTRIBUTING.md)
154
+ - [Credits](docs/guide/credits.md)
155
+ - MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { packagedAssetPath } from "./assets.js";
5
+ import { ensureDir, readFileSafe } from "./fs-utils.js";
6
+
7
+ export const CURSOR_HOOK_SCRIPT = ".cursor/hooks/context-guard-edit.mjs";
8
+ export const CURSOR_HOOKS_JSON = ".cursor/hooks.json";
9
+
10
+ /**
11
+ * Merge shipped hook entries without removing user hooks.
12
+ *
13
+ * @param {Record<string, unknown> | null | undefined} existing
14
+ * @param {Record<string, unknown>} template
15
+ * @returns {Record<string, unknown>}
16
+ */
17
+ export function mergeCursorHooksConfig(existing, template) {
18
+ /** @type {Record<string, unknown>} */
19
+ const result =
20
+ existing && typeof existing === "object"
21
+ ? structuredClone(existing)
22
+ : { version: 1, hooks: {} };
23
+
24
+ result.version = result.version ?? template.version ?? 1;
25
+ /** @type {Record<string, unknown[]>} */
26
+ const hooks =
27
+ result.hooks && typeof result.hooks === "object"
28
+ ? /** @type {Record<string, unknown[]>} */ (result.hooks)
29
+ : {};
30
+ result.hooks = hooks;
31
+
32
+ /** @type {Record<string, unknown[]>} */
33
+ const templateHooks =
34
+ template.hooks && typeof template.hooks === "object"
35
+ ? /** @type {Record<string, unknown[]>} */ (template.hooks)
36
+ : {};
37
+
38
+ for (const [event, entries] of Object.entries(templateHooks)) {
39
+ if (!Array.isArray(entries)) {
40
+ continue;
41
+ }
42
+
43
+ const current = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
44
+ for (const entry of entries) {
45
+ if (!entry || typeof entry !== "object") {
46
+ continue;
47
+ }
48
+ const command = /** @type {{ command?: string }} */ (entry).command;
49
+ if (!command || current.some((item) => item?.command === command)) {
50
+ continue;
51
+ }
52
+ current.push(entry);
53
+ }
54
+ hooks[event] = current;
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Install Cursor hooks that auto-run context-guard before write/edit tools.
62
+ *
63
+ * @param {string} cwd
64
+ * @param {{ log?: (message: string) => void }} [options]
65
+ */
66
+ export async function installCursorHooks(cwd, options = {}) {
67
+ const log = options.log ?? (() => {});
68
+ const hooksDir = path.join(cwd, ".cursor/hooks");
69
+ await ensureDir(hooksDir);
70
+
71
+ const scriptSource = packagedAssetPath("templates/cursor/hooks/context-guard-edit.mjs");
72
+ const scriptDest = path.join(cwd, CURSOR_HOOK_SCRIPT);
73
+ await fs.copyFile(scriptSource, scriptDest);
74
+
75
+ try {
76
+ await fs.chmod(scriptDest, 0o755);
77
+ } catch {
78
+ // Windows may ignore chmod; node can still execute the script.
79
+ }
80
+
81
+ const template = JSON.parse(
82
+ await readFileSafe(packagedAssetPath("templates/cursor/hooks.json")),
83
+ );
84
+
85
+ const hooksJsonPath = path.join(cwd, CURSOR_HOOKS_JSON);
86
+ let existing = null;
87
+ try {
88
+ existing = JSON.parse(await readFileSafe(hooksJsonPath));
89
+ } catch {
90
+ existing = null;
91
+ }
92
+
93
+ const merged = mergeCursorHooksConfig(existing, template);
94
+ await fs.writeFile(hooksJsonPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
95
+ log(`✅ Cursor hooks → ${CURSOR_HOOKS_JSON} (context-guard on write/edit tools)`);
96
+ }
package/lib/doctor.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { resolvePython, resolveScriptsDir } from "./gates.js";
12
12
  import { readFileSafe } from "./fs-utils.js";
13
13
  import { listFeatureIds, readActiveFeatureFromState } from "./specs-utils.js";
14
+ import { resolveMemoryHint } from "./memory-doctor.js";
14
15
 
15
16
  const execFileAsync = promisify(execFile);
16
17
 
@@ -368,7 +369,7 @@ export function topDoctorSuggestions(checks, limit = 3) {
368
369
  /**
369
370
  * @param {string} cwd
370
371
  * @param {{ suggest?: boolean, json?: boolean }} [options]
371
- * @returns {Promise<{ score: number, modes: ReturnType<typeof scoreDoctorModes>, checks: DoctorCheck[], suggestions: DoctorCheck[], executeHint: string | null, pythonMissing: boolean }>}
372
+ * @returns {Promise<{ score: number, modes: ReturnType<typeof scoreDoctorModes>, checks: DoctorCheck[], suggestions: DoctorCheck[], executeHint: string | null, memoryHint: string | null, pythonMissing: boolean }>}
372
373
  */
373
374
  export async function doctor(cwd, options = {}) {
374
375
  const checks = await runDoctorChecks(cwd);
@@ -377,18 +378,19 @@ export async function doctor(cwd, options = {}) {
377
378
  const suggestions = topDoctorSuggestions(checks);
378
379
  const activeFeature = await readActiveFeature(cwd);
379
380
  const executeHint = await resolveExecuteHint(cwd, activeFeature);
381
+ const memoryHint = await resolveMemoryHint(cwd);
380
382
  const pythonCheck = checks.find((check) => check.id === "python");
381
383
  const pythonMissing = pythonCheck ? !pythonCheck.pass : false;
382
384
 
383
385
  if (options.json) {
384
386
  console.log(
385
387
  JSON.stringify(
386
- { score, modes, checks, suggestions, executeHint, pythonMissing },
388
+ { score, modes, checks, suggestions, executeHint, memoryHint, pythonMissing },
387
389
  null,
388
390
  2,
389
391
  ),
390
392
  );
391
- return { score, modes, checks, suggestions, executeHint, pythonMissing };
393
+ return { score, modes, checks, suggestions, executeHint, memoryHint, pythonMissing };
392
394
  }
393
395
 
394
396
  if (pythonMissing) {
@@ -427,5 +429,9 @@ export async function doctor(cwd, options = {}) {
427
429
  console.log(`\nExecute hint:\n → ${executeHint}`);
428
430
  }
429
431
 
430
- return { score, modes, checks, suggestions, executeHint, pythonMissing };
432
+ if (memoryHint) {
433
+ console.log(`\nMemory hint:\n → ${memoryHint}`);
434
+ }
435
+
436
+ return { score, modes, checks, suggestions, executeHint, memoryHint, pythonMissing };
431
437
  }
package/lib/gates.js CHANGED
@@ -205,6 +205,47 @@ export async function runGuardrailsScript(command, args, options = {}) {
205
205
  });
206
206
  }
207
207
 
208
+ /**
209
+ * Run a guardrails Python script and capture stdout/stderr.
210
+ *
211
+ * @param {string} command gate or aux command name
212
+ * @param {string[]} args forwarded to the Python script
213
+ * @param {{ cwd?: string }} [options]
214
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
215
+ */
216
+ export async function runGuardrailsScriptCapture(command, args, options = {}) {
217
+ const scriptName = GUARDRAILS_SCRIPTS[command];
218
+
219
+ if (!scriptName) {
220
+ throw new Error(`Unknown guardrails command: ${command}`);
221
+ }
222
+
223
+ const cwd = options.cwd ?? process.cwd();
224
+ const scriptsDir = await resolveScriptsDir(cwd);
225
+ const scriptPath = path.join(cwd, scriptsDir, scriptName);
226
+ const python = await resolvePython();
227
+
228
+ if (!python) {
229
+ throw new Error("Python 3.10+ not found.");
230
+ }
231
+
232
+ return new Promise((resolve, reject) => {
233
+ let stdout = "";
234
+ let stderr = "";
235
+ const child = spawn(python.command, [...python.args, scriptPath, ...args], { cwd });
236
+ child.stdout.on("data", (chunk) => {
237
+ stdout += chunk;
238
+ });
239
+ child.stderr.on("data", (chunk) => {
240
+ stderr += chunk;
241
+ });
242
+ child.on("error", reject);
243
+ child.on("close", (code) => {
244
+ resolve({ code: code ?? 1, stdout, stderr });
245
+ });
246
+ });
247
+ }
248
+
208
249
  /**
209
250
  * Run a structural gate script installed under `.specs/guardrails/scripts/`.
210
251
  *
package/lib/install.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  resolveAssetOverride,
14
14
  } from "./constants.js";
15
15
  import { installPlatformAdapters } from "./adapters.js";
16
+ import { installCursorHooks } from "./cursor-hooks.js";
16
17
  import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
17
18
  import { hasPython } from "./gates.js";
18
19
  import { initGuardrailsMemory } from "./memory.js";
@@ -118,6 +119,8 @@ export async function install(options = {}) {
118
119
  await installPlatformAdapters(cwd);
119
120
  log("✅ Adapters → .cursorrules, CLAUDE.md, copilot-instructions.md, AGENTS.md, .codex/AGENTS.md");
120
121
 
122
+ await installCursorHooks(cwd, { log });
123
+
121
124
  const gettingStartedCreated = await writeFileIfMissing(
122
125
  path.join(cwd, ".specs/GETTING_STARTED.md"),
123
126
  await readFileSafe(packagedAssetPath("templates/GETTING_STARTED.md")),
@@ -0,0 +1,80 @@
1
+ import { NPX } from "./constants.js";
2
+ import { hasPython, runGuardrailsScriptCapture } from "./gates.js";
3
+
4
+ /**
5
+ * @typedef {{
6
+ * database: string,
7
+ * exists: boolean,
8
+ * semantic_enabled: boolean,
9
+ * provider: string,
10
+ * entities: number,
11
+ * chunks: number,
12
+ * embeddings: number,
13
+ * has_artifacts: boolean,
14
+ * stale: boolean,
15
+ * }} MemoryStatus
16
+ */
17
+
18
+ /**
19
+ * @param {string} cwd
20
+ * @returns {Promise<MemoryStatus | null>}
21
+ */
22
+ export async function readMemoryStatus(cwd) {
23
+ if (!(await hasPython())) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const { code, stdout } = await runGuardrailsScriptCapture(
29
+ "memory-index",
30
+ ["status", "--json"],
31
+ { cwd },
32
+ );
33
+ if (code !== 0) {
34
+ return null;
35
+ }
36
+ return JSON.parse(stdout);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {MemoryStatus} status
44
+ * @returns {string | null}
45
+ */
46
+ export function memoryHintFromStatus(status) {
47
+ if (!status.has_artifacts) {
48
+ return null;
49
+ }
50
+
51
+ if (!status.exists) {
52
+ return `${NPX("memory-index rebuild")} — project artifacts exist but the search index is missing`;
53
+ }
54
+
55
+ if (status.stale) {
56
+ return `${NPX("memory-index rebuild")} — .specs/ changed since the last index rebuild`;
57
+ }
58
+
59
+ if (
60
+ status.semantic_enabled &&
61
+ status.chunks > 0 &&
62
+ status.embeddings < Math.max(1, Math.ceil(status.chunks * 0.5))
63
+ ) {
64
+ return `${NPX("memory-index embed")} — semantic search is enabled but most chunks lack embeddings`;
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * @param {string} cwd
72
+ * @returns {Promise<string | null>}
73
+ */
74
+ export async function resolveMemoryHint(cwd) {
75
+ const status = await readMemoryStatus(cwd);
76
+ if (!status) {
77
+ return null;
78
+ }
79
+ return memoryHintFromStatus(status);
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.6.0",
4
- "description": "Keep AI coding agents honest specify the work, prove each step, verify independently. Process mode (Node) for flexibility; Brakes mode (Node + Python) for structural gates and a Guarantees matrix. Progressive loading, independent verify — any AI agent.",
3
+ "version": "3.8.0",
4
+ "description": "Spec-driven process kit for AI coding agents: write goals in .specs/, break into tasks, implement in waves, verify with proof. Process mode (Node) or Brakes mode (Node + Python gates). Works with Cursor, Claude, Copilot, and Codex.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spec-guardrails": "./index.js"
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "guardrails": "node index.js",
14
14
  "test": "npm run test:node && npm run test:gates",
15
- "test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js test/test_solution_exploration.test.js",
15
+ "test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js test/test_solution_exploration.test.js test/test_memory_doctor.test.js test/test_cursor_hooks.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -260,6 +260,82 @@ def chunk_spec_requirements(
260
260
  return count
261
261
 
262
262
 
263
+ def chunk_task_bodies(
264
+ conn: sqlite3.Connection,
265
+ feature_id: str,
266
+ tasks_path: Path,
267
+ tasks_text: str,
268
+ now: str,
269
+ ) -> int:
270
+ count = 0
271
+ matches = list(TASK_HEADING.finditer(tasks_text))
272
+ for index, match in enumerate(matches):
273
+ task_id = match.group("id").upper()
274
+ start = match.end()
275
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(tasks_text)
276
+ body = tasks_text[start:end].strip()
277
+ if not body:
278
+ body = match.group("title").strip() or task_id
279
+ chunk_id = f"chunk:{feature_id}:task:{task_id}"
280
+ upsert_chunk(conn, chunk_id, task_id, "task", str(tasks_path), body, now)
281
+ count += 1
282
+ return count
283
+
284
+
285
+ def prune_embeddings(conn: sqlite3.Connection) -> None:
286
+ conn.execute(
287
+ """
288
+ DELETE FROM embeddings
289
+ WHERE chunk_id NOT IN (SELECT id FROM chunks)
290
+ OR chunk_id IN (
291
+ SELECT e.chunk_id
292
+ FROM embeddings e
293
+ JOIN chunks c ON c.id = e.chunk_id
294
+ WHERE e.text_hash != c.text_hash
295
+ )
296
+ """
297
+ )
298
+
299
+
300
+ def latest_artifact_mtime() -> float | None:
301
+ latest: float | None = None
302
+ candidates = [SPECS_DIR / "lessons.json", SPECS_DIR / "STATE.md"]
303
+ if FEATURES_DIR.is_dir():
304
+ for feature_dir in FEATURES_DIR.iterdir():
305
+ if not feature_dir.is_dir():
306
+ continue
307
+ for name in (
308
+ "spec.md",
309
+ "tasks.md",
310
+ "design.md",
311
+ "validation.md",
312
+ "exploration.md",
313
+ ):
314
+ path = feature_dir / name
315
+ if path.is_file():
316
+ candidates.append(path)
317
+
318
+ for path in candidates:
319
+ if path.is_file():
320
+ mtime = path.stat().st_mtime
321
+ latest = mtime if latest is None else max(latest, mtime)
322
+ return latest
323
+
324
+
325
+ def has_indexable_artifacts() -> bool:
326
+ if (SPECS_DIR / "lessons.json").is_file():
327
+ return True
328
+ if not FEATURES_DIR.is_dir():
329
+ return False
330
+ for feature_dir in FEATURES_DIR.iterdir():
331
+ if not feature_dir.is_dir():
332
+ continue
333
+ for name in ("spec.md", "tasks.md", "design.md", "validation.md", "exploration.md"):
334
+ if (feature_dir / name).is_file():
335
+ return True
336
+ return False
337
+
338
+
263
339
  def chunk_markdown_sections(
264
340
  conn: sqlite3.Connection,
265
341
  feature_id: str,
@@ -343,7 +419,6 @@ def rebuild(json_output: bool = False) -> int:
343
419
  conn.execute("DELETE FROM entity_fts")
344
420
  conn.execute("DELETE FROM chunks")
345
421
  conn.execute("DELETE FROM chunk_fts")
346
- conn.execute("DELETE FROM embeddings")
347
422
 
348
423
  entity_count = 0
349
424
  relation_count = 0
@@ -397,6 +472,19 @@ def rebuild(json_output: bool = False) -> int:
397
472
  now,
398
473
  )
399
474
 
475
+ design_path = feature_dir / "design.md"
476
+ if design_path.is_file():
477
+ design_text = design_path.read_text(encoding="utf-8")
478
+ chunk_count += chunk_markdown_sections(
479
+ conn,
480
+ feature_id,
481
+ design_path,
482
+ design_text,
483
+ "design",
484
+ feature_id,
485
+ now,
486
+ )
487
+
400
488
  exploration_path = feature_dir / "exploration.md"
401
489
  if exploration_path.is_file():
402
490
  exploration_text = exploration_path.read_text(encoding="utf-8")
@@ -448,9 +536,14 @@ def rebuild(json_output: bool = False) -> int:
448
536
  upsert_relation(conn, task_id, file_entity, "touches")
449
537
  relation_count += 1
450
538
 
539
+ chunk_count += chunk_task_bodies(
540
+ conn, feature_id, tasks_path, tasks_text, now
541
+ )
542
+
451
543
  lesson_entities, lesson_chunks = index_lessons(conn, now)
452
544
  entity_count += lesson_entities
453
545
  chunk_count += lesson_chunks
546
+ prune_embeddings(conn)
454
547
  conn.commit()
455
548
 
456
549
  summary = {
@@ -545,6 +638,46 @@ def embed_chunks(force: bool = False, json_output: bool = False) -> int:
545
638
  return EXIT_OK
546
639
 
547
640
 
641
+ def memory_status(json_output: bool = False) -> int:
642
+ config = load_memory_retrieval_config()
643
+ latest = latest_artifact_mtime()
644
+ summary = {
645
+ "database": str(DB_PATH),
646
+ "exists": DB_PATH.is_file(),
647
+ "semantic_enabled": bool(config.get("semantic")),
648
+ "provider": str(config.get("provider") or "none"),
649
+ "entities": 0,
650
+ "chunks": 0,
651
+ "embeddings": 0,
652
+ "has_artifacts": has_indexable_artifacts(),
653
+ "stale": False,
654
+ }
655
+
656
+ if DB_PATH.is_file():
657
+ conn = sqlite3.connect(DB_PATH)
658
+ try:
659
+ init_schema(conn)
660
+ summary["entities"] = conn.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
661
+ summary["chunks"] = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
662
+ summary["embeddings"] = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
663
+ finally:
664
+ conn.close()
665
+ if latest is not None:
666
+ summary["stale"] = DB_PATH.stat().st_mtime < latest
667
+ else:
668
+ summary["stale"] = summary["has_artifacts"]
669
+
670
+ if json_output:
671
+ print(json.dumps(summary, indent=2))
672
+ else:
673
+ state = "ready" if summary["exists"] and not summary["stale"] else "needs attention"
674
+ print(
675
+ f"[{GATE}] memory index {state} — "
676
+ f"{summary['chunks']} chunk(s), {summary['embeddings']} embedding(s)"
677
+ )
678
+ return EXIT_OK
679
+
680
+
548
681
  def build_parser() -> argparse.ArgumentParser:
549
682
  parser = argparse.ArgumentParser(description="Rebuild SQLite memory index from .specs/")
550
683
  sub = parser.add_subparsers(dest="command")
@@ -558,6 +691,10 @@ def build_parser() -> argparse.ArgumentParser:
558
691
  embed_cmd.add_argument("--json", action="store_true")
559
692
  embed_cmd.set_defaults(func=lambda args: embed_chunks(force=args.force, json_output=args.json))
560
693
 
694
+ status_cmd = sub.add_parser("status", help="report index stats for doctor and tooling")
695
+ status_cmd.add_argument("--json", action="store_true")
696
+ status_cmd.set_defaults(func=lambda args: memory_status(json_output=args.json))
697
+
561
698
  return parser
562
699
 
563
700
 
@@ -48,7 +48,7 @@ Structural gates run **before** owner review, so they cannot drift when the mode
48
48
  | Before Execute waves (3+ tasks) | `npx @luizsantiago/spec-guardrails loop-plan [feature]` |
49
49
  | Parallel wave (2+ tasks, disjoint Files) | `npx @luizsantiago/spec-guardrails workspace-prepare [feature] --tasks T1,T2` |
50
50
  | After parallel wave merge | `npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --force` |
51
- | Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails context-guard check-edit <path> [--op write]` |
51
+ | Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails context-guard check-edit <path> [--op write]` — **Cursor:** also auto-runs via `.cursor/hooks/` on write/edit tools |
52
52
  | Before claiming feature complete | `npx @luizsantiago/spec-guardrails context-guard check-complete [feature]` |
53
53
  | Solution exploration (explicit) | `npx @luizsantiago/spec-guardrails solution-explore init <feature> --candidates A,B` |
54
54
  | Before exploration decision | `npx @luizsantiago/spec-guardrails solution-explore validate [feature]` |
@@ -16,6 +16,8 @@ You installed the **Spec Guardrails**. You do **not** need to memorize CLI comma
16
16
 
17
17
  3. Review `.specs/features/…/spec.md` and **approve** before implementation.
18
18
 
19
+ **Plain-language guide:** [Overview](https://github.com/luizssantiago92/spec-guardrails/blob/main/docs/guide/Overview.md) · [Memory](https://github.com/luizssantiago92/spec-guardrails/blob/main/docs/guide/Memory.md)
20
+
19
21
  ---
20
22
 
21
23
  ## Agent commands (chat — not terminal)
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cursor preToolUse hook — runs context-guard before file write/edit tools.
4
+ * Installed to .cursor/hooks/ by spec-guardrails install.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { readFileSync } from "node:fs";
8
+ import { spawnSync } from "node:child_process";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ const GUARDRAILS_SCRIPTS = ".specs/guardrails/scripts";
12
+ const SKIP_PREFIXES = [".specs/", ".cursor/", "node_modules/"];
13
+ const WRITE_TOOLS = new Set([
14
+ "write",
15
+ "strreplace",
16
+ "search_replace",
17
+ "editnotebook",
18
+ "applypatch",
19
+ "edit",
20
+ ]);
21
+
22
+ /**
23
+ * @param {unknown} input
24
+ * @returns {Record<string, unknown> | null}
25
+ */
26
+ function readHookInput(input) {
27
+ if (input && typeof input === "object") {
28
+ return /** @type {Record<string, unknown>} */ (input);
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * @param {Record<string, unknown>} input
35
+ * @returns {string | null}
36
+ */
37
+ export function extractEditPath(input) {
38
+ const toolName = String(input.tool_name ?? input.toolName ?? "").toLowerCase();
39
+ if (toolName && !WRITE_TOOLS.has(toolName)) {
40
+ return null;
41
+ }
42
+
43
+ const rawInput = input.tool_input ?? input.toolInput ?? input.arguments ?? {};
44
+ /** @type {Record<string, unknown>} */
45
+ let toolInput = {};
46
+
47
+ if (typeof rawInput === "string") {
48
+ try {
49
+ toolInput = JSON.parse(rawInput);
50
+ } catch {
51
+ return null;
52
+ }
53
+ } else if (rawInput && typeof rawInput === "object") {
54
+ toolInput = /** @type {Record<string, unknown>} */ (rawInput);
55
+ }
56
+
57
+ const pathValue =
58
+ toolInput.path ??
59
+ toolInput.file_path ??
60
+ toolInput.filePath ??
61
+ toolInput.target_notebook ??
62
+ toolInput.notebook_path;
63
+
64
+ return typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : null;
65
+ }
66
+
67
+ /**
68
+ * @param {string | null} relativePath
69
+ * @returns {boolean}
70
+ */
71
+ export function shouldSkipPath(relativePath) {
72
+ if (!relativePath) {
73
+ return true;
74
+ }
75
+
76
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
77
+ return SKIP_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix));
78
+ }
79
+
80
+ /**
81
+ * @param {{ permission: string, user_message?: string, agent_message?: string }} payload
82
+ */
83
+ function respond(payload) {
84
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
85
+ }
86
+
87
+ function allow() {
88
+ respond({ permission: "allow" });
89
+ process.exit(0);
90
+ }
91
+
92
+ /**
93
+ * @param {string[]} messages
94
+ */
95
+ function deny(messages) {
96
+ const detail = messages.filter(Boolean).join("; ") || "context-guard blocked this edit";
97
+ respond({
98
+ permission: "deny",
99
+ user_message: `Spec Guardrails blocked this edit: ${detail}`,
100
+ agent_message:
101
+ `Context guard blocked the edit. Align with .specs/STATE.md and task Files, or update tasks.md. ${detail}`,
102
+ });
103
+ process.exit(2);
104
+ }
105
+
106
+ /**
107
+ * @param {Record<string, unknown>} input
108
+ * @returns {{ allowed: boolean, messages: string[] }}
109
+ */
110
+ export function evaluateHookInput(input) {
111
+ const editPath = extractEditPath(input);
112
+ if (shouldSkipPath(editPath)) {
113
+ return { allowed: true, messages: [] };
114
+ }
115
+
116
+ if (!existsSync(GUARDRAILS_SCRIPTS)) {
117
+ return { allowed: true, messages: [] };
118
+ }
119
+
120
+ const cli = process.env.SPEC_GUARDRAILS_CLI ?? "npx @luizsantiago/spec-guardrails";
121
+ const result = spawnSync(
122
+ cli,
123
+ ["context-guard", "check-edit", editPath, "--op", "write", "--json"],
124
+ { encoding: "utf8", shell: process.platform === "win32" },
125
+ );
126
+
127
+ if (result.status === 0) {
128
+ return { allowed: true, messages: [] };
129
+ }
130
+
131
+ /** @type {string[]} */
132
+ let messages = ["context-guard check failed"];
133
+ try {
134
+ const parsed = JSON.parse(result.stdout || "{}");
135
+ if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
136
+ messages = parsed.messages.map(String);
137
+ }
138
+ } catch {
139
+ if (result.stderr?.trim()) {
140
+ messages = [result.stderr.trim()];
141
+ }
142
+ }
143
+
144
+ return { allowed: false, messages };
145
+ }
146
+
147
+ function main() {
148
+ let parsed = null;
149
+ try {
150
+ parsed = readHookInput(JSON.parse(readFileSync(0, "utf8")));
151
+ } catch {
152
+ allow();
153
+ }
154
+
155
+ if (!parsed) {
156
+ allow();
157
+ }
158
+
159
+ const decision = evaluateHookInput(parsed);
160
+ if (decision.allowed) {
161
+ allow();
162
+ }
163
+
164
+ deny(decision.messages);
165
+ }
166
+
167
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
168
+ if (entry && import.meta.url === entry) {
169
+ main();
170
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "preToolUse": [
5
+ {
6
+ "command": ".cursor/hooks/context-guard-edit.mjs",
7
+ "matcher": "Write|StrReplace|search_replace|EditNotebook|ApplyPatch",
8
+ "failClosed": false
9
+ }
10
+ ]
11
+ }
12
+ }