@luizsantiago/spec-guardrails 3.2.1 → 3.3.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
@@ -11,7 +11,7 @@
11
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
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
13
 
14
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.2.x**
14
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.3.x**
15
15
 
16
16
  ---
17
17
 
@@ -217,6 +217,7 @@ npx @luizsantiago/spec-guardrails install
217
217
 
218
218
  | Version | What you gain |
219
219
  | --- | --- |
220
+ | **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
220
221
  | **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
221
222
  | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
222
223
  | **3.0.x** | Final name Spec Guardrails; `.specs/guardrails/`; no dual-path ([Migration](docs/guide/Migration.md)) |
package/index.js CHANGED
@@ -81,6 +81,7 @@ Commands:
81
81
  execution-policy status Show configured budgets, scope, and runtime counters
82
82
  [--json] Machine-readable output
83
83
  execution-policy check-path <path> Check whether a relative path is allowed by scope policy
84
+ [--op read|write|delete] Intended operation (default: inferred from path)
84
85
  [--json] Machine-readable output
85
86
  execution-policy record-retry <task> Increment retry counter for a task id (blocks at limit)
86
87
  execution-policy record-run Increment agent-run counter (blocks at budget)
@@ -482,20 +483,41 @@ if (command === "--version" || command === "-v" || command === "version") {
482
483
  if (sub === "status") {
483
484
  process.stdout.write(formatPolicyStatus(policy, state, { json }));
484
485
  } else if (sub === "check-path") {
485
- const relativePath = rest[0];
486
+ let operation;
487
+ const positional = [];
488
+
489
+ for (let i = 0; i < rest.length; i++) {
490
+ const arg = rest[i];
491
+ if (arg === "--op") {
492
+ operation = rest[++i];
493
+ if (!operation) {
494
+ throw new Error("--op requires read, write, or delete");
495
+ }
496
+ } else {
497
+ positional.push(arg);
498
+ }
499
+ }
500
+
501
+ const relativePath = positional[0];
486
502
  if (!relativePath) {
487
- throw new Error("Usage: execution-policy check-path <relative-path>");
503
+ throw new Error(
504
+ "Usage: execution-policy check-path <relative-path> [--op read|write|delete]",
505
+ );
488
506
  }
489
- const result = resolvePathCheck(relativePath, policy);
507
+ const result = resolvePathCheck(relativePath, policy, { operation });
490
508
  if (json) {
491
509
  console.log(JSON.stringify({ path: relativePath, ...result }, null, 2));
492
510
  } else {
493
511
  const label = result.allowed
494
- ? "allowed"
512
+ ? result.severity === "warning"
513
+ ? "allowed (warn)"
514
+ : "allowed"
495
515
  : result.severity === "warning"
496
516
  ? "blocked (warn)"
497
517
  : "blocked";
498
- console.log(`${relativePath}: ${label} (${result.reason})`);
518
+ console.log(
519
+ `${relativePath} [${result.operation}]: ${label} (${result.reason})`,
520
+ );
499
521
  }
500
522
  if (result.exitCode !== 0) {
501
523
  process.exit(result.exitCode);
@@ -21,6 +21,14 @@ export const DEFAULT_POLICY = {
21
21
  on_budget_exhaustion: "stop",
22
22
  on_policy_violation: "block",
23
23
  },
24
+ effects: {
25
+ deny_read: [],
26
+ deny_write: [],
27
+ deny_delete: ["**/.git/**"],
28
+ warn_read: [],
29
+ warn_write: [],
30
+ warn_delete: [],
31
+ },
24
32
  };
25
33
 
26
34
  /**
@@ -28,9 +36,15 @@ export const DEFAULT_POLICY = {
28
36
  * budget: { max_iterations: number, max_agent_runs: number, max_retries_per_task: number },
29
37
  * scope: { allowed_paths: string[], denied_paths: string[] },
30
38
  * escalation: { on_scope_expansion: string, on_budget_exhaustion: string, on_policy_violation: string },
39
+ * effects: {
40
+ * deny_read: string[], deny_write: string[], deny_delete: string[],
41
+ * warn_read: string[], warn_write: string[], warn_delete: string[],
42
+ * },
31
43
  * }} ExecutionPolicy
32
44
  */
33
45
 
46
+ /** @typedef {"read" | "write" | "delete"} PathOperation */
47
+
34
48
  /**
35
49
  * @typedef {{
36
50
  * iterations: number,
@@ -48,7 +62,7 @@ export const DEFAULT_POLICY = {
48
62
  export function parseExecutionPolicySections(text) {
49
63
  /** @type {Partial<ExecutionPolicy>} */
50
64
  const result = {};
51
- /** @type {"budget" | "scope" | "escalation" | null} */
65
+ /** @type {"budget" | "scope" | "escalation" | "effects" | null} */
52
66
  let section = null;
53
67
  /** @type {string | null} */
54
68
  let listKey = null;
@@ -59,9 +73,9 @@ export function parseExecutionPolicySections(text) {
59
73
  continue;
60
74
  }
61
75
 
62
- const sectionMatch = trimmed.match(/^(budget|scope|escalation):\s*$/);
76
+ const sectionMatch = trimmed.match(/^(budget|scope|escalation|effects):\s*$/);
63
77
  if (sectionMatch) {
64
- section = /** @type {"budget" | "scope" | "escalation"} */ (sectionMatch[1]);
78
+ section = /** @type {"budget" | "scope" | "escalation" | "effects"} */ (sectionMatch[1]);
65
79
  result[section] = result[section] ?? {};
66
80
  listKey = null;
67
81
  continue;
@@ -112,6 +126,14 @@ export function mergeExecutionPolicy(base, overlay) {
112
126
  denied_paths: overlay.scope?.denied_paths ?? base.scope.denied_paths,
113
127
  },
114
128
  escalation: { ...base.escalation, ...(overlay.escalation ?? {}) },
129
+ effects: {
130
+ deny_read: overlay.effects?.deny_read ?? base.effects.deny_read,
131
+ deny_write: overlay.effects?.deny_write ?? base.effects.deny_write,
132
+ deny_delete: overlay.effects?.deny_delete ?? base.effects.deny_delete,
133
+ warn_read: overlay.effects?.warn_read ?? base.effects.warn_read,
134
+ warn_write: overlay.effects?.warn_write ?? base.effects.warn_write,
135
+ warn_delete: overlay.effects?.warn_delete ?? base.effects.warn_delete,
136
+ },
115
137
  };
116
138
  }
117
139
 
@@ -191,6 +213,154 @@ export function checkPathScope(relativePath, policy) {
191
213
  };
192
214
  }
193
215
 
216
+ const PATH_OPERATIONS = new Set(["read", "write", "delete"]);
217
+
218
+ /**
219
+ * @param {string | undefined | null} raw
220
+ * @returns {PathOperation}
221
+ */
222
+ export function normalizePathOperation(raw) {
223
+ const value = String(raw ?? "write").trim().toLowerCase();
224
+ if (!PATH_OPERATIONS.has(value)) {
225
+ throw new Error(`unknown operation '${raw}' — use read, write, or delete`);
226
+ }
227
+ return /** @type {PathOperation} */ (value);
228
+ }
229
+
230
+ /**
231
+ * Heuristic intent when the agent did not declare an operation explicitly.
232
+ *
233
+ * @param {string} relativePath
234
+ * @returns {PathOperation}
235
+ */
236
+ export function inferPathOperation(relativePath) {
237
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
238
+ const readOnly = /\.(md|txt|json|yaml|yml|lock)$/i.test(normalized);
239
+ return readOnly ? "read" : "write";
240
+ }
241
+
242
+ /**
243
+ * @param {PathOperation} operation
244
+ * @param {ExecutionPolicy["effects"]} effects
245
+ * @returns {{ deny: string[], warn: string[] }}
246
+ */
247
+ export function effectRulesForOperation(operation, effects) {
248
+ return {
249
+ deny: effects[`deny_${operation}`] ?? [],
250
+ warn: effects[`warn_${operation}`] ?? [],
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @param {string} relativePath
256
+ * @param {PathOperation} operation
257
+ * @param {ExecutionPolicy} policy
258
+ * @returns {{ allowed: boolean, severity: "blocking" | "warning" | "info", reason: string, operation: PathOperation }}
259
+ */
260
+ export function checkPathEffect(relativePath, operation, policy) {
261
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
262
+ const { deny, warn } = effectRulesForOperation(operation, policy.effects);
263
+
264
+ for (const pattern of deny) {
265
+ if (matchGlobPattern(pattern, normalized)) {
266
+ return {
267
+ allowed: false,
268
+ severity: "blocking",
269
+ reason: `${operation.toUpperCase()} denied for path matching ${pattern}`,
270
+ operation,
271
+ };
272
+ }
273
+ }
274
+
275
+ for (const pattern of warn) {
276
+ if (matchGlobPattern(pattern, normalized)) {
277
+ return {
278
+ allowed: true,
279
+ severity: "warning",
280
+ reason: `${operation.toUpperCase()} warned for path matching ${pattern}`,
281
+ operation,
282
+ };
283
+ }
284
+ }
285
+
286
+ return {
287
+ allowed: true,
288
+ severity: "info",
289
+ reason: `no ${operation} effect rules matched`,
290
+ operation,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * @param {string} relativePath
296
+ * @param {ExecutionPolicy} policy
297
+ * @param {{ operation?: string | null }} [options]
298
+ * @returns {{
299
+ * allowed: boolean,
300
+ * exitCode: number,
301
+ * severity: "blocking" | "warning" | "info",
302
+ * reason: string,
303
+ * operation: PathOperation,
304
+ * scope: ReturnType<typeof checkPathScope>,
305
+ * effect: ReturnType<typeof checkPathEffect>,
306
+ * }}
307
+ */
308
+ export function resolvePathCheck(relativePath, policy, options = {}) {
309
+ const operation = options.operation
310
+ ? normalizePathOperation(options.operation)
311
+ : inferPathOperation(relativePath);
312
+ const scope = checkPathScope(relativePath, policy);
313
+ const effect = checkPathEffect(relativePath, operation, policy);
314
+
315
+ if (!scope.allowed) {
316
+ const mode = policy.escalation.on_policy_violation ?? "block";
317
+ return {
318
+ allowed: false,
319
+ severity: mode === "warn" ? "warning" : "blocking",
320
+ reason: scope.reason,
321
+ operation,
322
+ scope,
323
+ effect,
324
+ exitCode: mode === "warn" ? 0 : 1,
325
+ };
326
+ }
327
+
328
+ if (!effect.allowed) {
329
+ const mode = policy.escalation.on_policy_violation ?? "block";
330
+ return {
331
+ allowed: false,
332
+ severity: mode === "warn" ? "warning" : "blocking",
333
+ reason: effect.reason,
334
+ operation,
335
+ scope,
336
+ effect,
337
+ exitCode: mode === "warn" ? 0 : 1,
338
+ };
339
+ }
340
+
341
+ if (effect.severity === "warning") {
342
+ return {
343
+ allowed: true,
344
+ severity: "warning",
345
+ reason: effect.reason,
346
+ operation,
347
+ scope,
348
+ effect,
349
+ exitCode: 0,
350
+ };
351
+ }
352
+
353
+ return {
354
+ allowed: true,
355
+ severity: "info",
356
+ reason: scope.reason,
357
+ operation,
358
+ scope,
359
+ effect,
360
+ exitCode: 0,
361
+ };
362
+ }
363
+
194
364
  /**
195
365
  * @param {string} cwd
196
366
  * @returns {Promise<ExecutionPolicyState>}
@@ -235,25 +405,6 @@ export function checkBudget(state, policy) {
235
405
  return { ok: issues.length === 0, issues };
236
406
  }
237
407
 
238
- /**
239
- * @param {string} relativePath
240
- * @param {ExecutionPolicy} policy
241
- * @returns {{ allowed: boolean, exitCode: number, severity: "blocking" | "warning" | "info", reason: string }}
242
- */
243
- export function resolvePathCheck(relativePath, policy) {
244
- const result = checkPathScope(relativePath, policy);
245
- if (result.allowed) {
246
- return { ...result, exitCode: 0 };
247
- }
248
-
249
- const mode = policy.escalation.on_policy_violation ?? "block";
250
- if (mode === "warn") {
251
- return { ...result, severity: "warning", exitCode: 0 };
252
- }
253
-
254
- return { ...result, exitCode: 1 };
255
- }
256
-
257
408
  /**
258
409
  * @param {string} taskId
259
410
  * @param {ExecutionPolicyState} state
@@ -361,6 +512,7 @@ export function formatPolicyStatus(policy, state, options = {}) {
361
512
  ` agent_runs: ${state.agent_runs}/${policy.budget.max_agent_runs}`,
362
513
  ` allowed_paths: ${policy.scope.allowed_paths.length ? policy.scope.allowed_paths.join(", ") : "(none — all non-denied paths allowed)"}`,
363
514
  ` denied_paths: ${policy.scope.denied_paths.join(", ")}`,
515
+ ` deny_delete: ${policy.effects.deny_delete.join(", ") || "(default .git only)"}`,
364
516
  ` budget_ok: ${budget.ok ? "yes" : "no"}`,
365
517
  ];
366
518
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 execution-policy check-path <path>` |
51
+ | Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails execution-policy check-path <path> [--op write]` |
52
52
  | On gate retry (Execute playbook) | `npx @luizsantiago/spec-guardrails execution-policy record-retry Tn` |
53
53
  | On each commit | `python3 .specs/guardrails/scripts/check_commit.py --message "<message>"` |
54
54
  | Before declaring a feature done | `python3 .specs/guardrails/scripts/validate_state.py [feature]` |
@@ -72,6 +72,7 @@ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
72
72
  ```
73
73
 
74
74
  - **Scope:** denied paths block with exit 1; allowed_paths (when set) restrict edits to listed globs.
75
+ - **Intent/effect:** pass `--op read|write|delete` to `check-path`; configure `effects.deny_*` and `effects.warn_*` globs per operation in `.specs/config.yaml`.
75
76
  - **Retries:** after a task gate failure, run `execution-policy record-retry Tn`; stop at `max_retries_per_task` (default 3) and escalate per the Execute playbook — do not bypass with `--no-verify`.
76
77
  - **Agent runs:** orchestrators may call `execution-policy record-run` when dispatching sub-agents; stop when budgets exhaust.
77
78
 
@@ -41,10 +41,13 @@ npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --tasks T1,T2 --fo
41
41
  7. Consult execution policy before touching files outside the task list:
42
42
 
43
43
  ```bash
44
- npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts
44
+ npx @luizsantiago/spec-guardrails execution-policy check-path src/auth.ts --op write
45
+ npx @luizsantiago/spec-guardrails execution-policy check-path README.md --op read
45
46
  npx @luizsantiago/spec-guardrails execution-policy status
46
47
  ```
47
48
 
49
+ Declare the intended operation (`read`, `write`, or `delete`). When `--op` is omitted, the CLI infers `read` for common doc/config extensions and `write` otherwise. Effect rules in `.specs/config.yaml` (`effects.deny_*`, `effects.warn_*`) apply after scope checks.
50
+
48
51
  Record gate retries with `execution-policy record-retry T1` when a task fails its gate (respects `max_retries_per_task` in `.specs/config.yaml`).
49
52
 
50
53
  8. When `loop-plan` shows a **parallel group** (2+ tasks), offer sub-agent dispatch per `task-graph-engineering.md` and `sub-agents.md`. Offer and wait; never auto-spawn. Large features (roughly 8+ tasks total) also warrant batching across waves.
@@ -39,6 +39,16 @@ escalation:
39
39
  on_budget_exhaustion: stop
40
40
  on_policy_violation: block
41
41
 
42
+ # Intent/effect rules (optional — pair with check-path --op read|write|delete)
43
+ effects:
44
+ deny_delete:
45
+ - "**/.git/**"
46
+ deny_write: []
47
+ deny_read: []
48
+ warn_write: []
49
+ warn_read: []
50
+ warn_delete: []
51
+
42
52
  # Project-specific overrides (appended on top of preset + rules above):
43
53
  # overrides:
44
54
  # rules: