@luizsantiago/spec-guardrails 3.2.0 → 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/lib/install.js CHANGED
@@ -13,8 +13,6 @@ import {
13
13
  resolveAssetOverride,
14
14
  } from "./constants.js";
15
15
  import { installPlatformAdapters } from "./adapters.js";
16
- import { injectClaudeMd } from "./claude-md.js";
17
- import { injectCursorRules } from "./cursorrules.js";
18
16
  import { ensureDir, readFileSafe, writeFileIfMissing } from "./fs-utils.js";
19
17
  import { hasPython } from "./gates.js";
20
18
  import { initGuardrailsMemory } from "./memory.js";
@@ -117,8 +115,6 @@ export async function install(options = {}) {
117
115
  }
118
116
 
119
117
  log("🔗 Installing platform adapters...");
120
- await injectCursorRules(cwd);
121
- await injectClaudeMd(cwd);
122
118
  await installPlatformAdapters(cwd);
123
119
  log("✅ Adapters → .cursorrules, CLAUDE.md, copilot-instructions.md, AGENTS.md, .codex/AGENTS.md");
124
120
 
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+
3
+ import { GUARDRAILS_SCRIPTS_DIR } from "./constants.js";
4
+ import { runGuardrailsScript } from "./gates.js";
5
+
6
+ export const MEMORY_DB_PATH = ".specs/memory/memory.db";
7
+
8
+ /**
9
+ * Rebuild the SQLite memory index from `.specs/` markdown artifacts.
10
+ *
11
+ * @param {{ cwd?: string }} [options]
12
+ * @returns {Promise<number>} exit code
13
+ */
14
+ export async function rebuildMemoryIndex(options = {}) {
15
+ const cwd = options.cwd ?? process.cwd();
16
+ return runGuardrailsScript("memory-index", ["rebuild"], { cwd });
17
+ }
18
+
19
+ /**
20
+ * Query the knowledge graph for a bounded context package.
21
+ *
22
+ * @param {{ from: string, depth?: number, json?: boolean, cwd?: string }} options
23
+ * @returns {Promise<number>} exit code
24
+ */
25
+ export async function queryMemory(options) {
26
+ const cwd = options.cwd ?? process.cwd();
27
+ const args = ["--from", options.from];
28
+ if (options.depth !== undefined) {
29
+ args.push("--depth", String(options.depth));
30
+ }
31
+ if (options.json) {
32
+ args.push("--json");
33
+ }
34
+ return runGuardrailsScript("memory-query", args, { cwd });
35
+ }
36
+
37
+ /**
38
+ * @param {string} [cwd]
39
+ * @returns {string}
40
+ */
41
+ export function memoryDbPath(cwd = process.cwd()) {
42
+ return path.join(cwd, MEMORY_DB_PATH);
43
+ }
44
+
45
+ /**
46
+ * @returns {string}
47
+ */
48
+ export function memoryDbRelativePath() {
49
+ return MEMORY_DB_PATH;
50
+ }
51
+
52
+ /**
53
+ * Relative scripts directory for memory tooling diagnostics.
54
+ *
55
+ * @returns {string}
56
+ */
57
+ export function memoryScriptsDir() {
58
+ return GUARDRAILS_SCRIPTS_DIR;
59
+ }
@@ -137,6 +137,39 @@ export async function cleanupWorkspaces(cwd, { featureId, taskIds, force = false
137
137
  return results;
138
138
  }
139
139
 
140
+ /**
141
+ * @param {string} cwd
142
+ * @param {string} featureId
143
+ * @returns {Promise<Array<{ taskId: string, path: string }>>}
144
+ */
145
+ export async function listWorkspaces(cwd, featureId) {
146
+ const paths = await listWorkspacePaths(cwd, featureId);
147
+ return paths.map((wtPath) => ({
148
+ taskId: path.basename(wtPath),
149
+ path: wtPath,
150
+ }));
151
+ }
152
+
153
+ /**
154
+ * @param {Awaited<ReturnType<typeof listWorkspaces>>} workspaces
155
+ * @param {{ json?: boolean, featureId?: string }} [options]
156
+ */
157
+ export function formatWorkspaceList(workspaces, options = {}) {
158
+ if (options.json) {
159
+ return JSON.stringify({ feature: options.featureId, workspaces }, null, 2);
160
+ }
161
+
162
+ const lines = ["Workspace list:"];
163
+ if (!workspaces.length) {
164
+ lines.push(" (none)");
165
+ } else {
166
+ for (const item of workspaces) {
167
+ lines.push(` ${item.taskId}: ${item.path}`);
168
+ }
169
+ }
170
+ return `${lines.join("\n")}\n`;
171
+ }
172
+
140
173
  /**
141
174
  * @param {ReturnType<typeof prepareWorkspaces> extends Promise<infer T> ? T : never} results
142
175
  * @param {{ json?: boolean }} [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.2.0",
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": {
@@ -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",
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",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -47,8 +47,10 @@ Gates live in `.specs/guardrails/scripts/` (Python 3). **The agent runs them** a
47
47
  | `feature-init "…"` | Optional — or ask the agent to `/specify` |
48
48
  | `project-init` | Optional — brownfield repo with existing code |
49
49
  | `doctor` | Install looks broken |
50
+ | `workspace-list <feature>` | Inspect parallel worktrees before cleanup |
51
+ | `execution-policy status` | Check budgets and scope before expanding Execute |
50
52
 
51
- Everything else (`validate-spec`, `validate-tasks`, `check-commit`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
53
+ Everything else (`validate-spec`, `validate-tasks`, `check-commit`, `workspace-prepare`, `execution-policy check-path`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
52
54
 
53
55
  Non-zero gate exit = STOP. Without Python, the agent performs the same checks manually.
54
56
 
@@ -46,6 +46,15 @@ STATE_FEATURE = re.compile(
46
46
  r"^\s*-\s*Feature:\s*(.+)$",
47
47
  re.IGNORECASE | re.MULTILINE,
48
48
  )
49
+ TASK_HEADING = re.compile(
50
+ r"^#{2,6}\s*(?P<id>T\d{1,6})\s*[:\-–]?\s*(?P<title>.*)$",
51
+ re.MULTILINE | re.IGNORECASE,
52
+ )
53
+ VERIFY_HINT = re.compile(
54
+ r"\b(test strategy|verification|validate\.md|/verify|acceptance test)\b",
55
+ re.IGNORECASE,
56
+ )
57
+ MEDIUM_TASK_FLOOR = 5
49
58
 
50
59
 
51
60
  def git_branch(root: Path) -> str | None:
@@ -121,6 +130,20 @@ def build_report(feature_dir: Path, root: Path) -> Report:
121
130
  else:
122
131
  report.warn("tasks.md missing or empty — task coverage checks skipped")
123
132
 
133
+ task_count = len(TASK_HEADING.findall(tasks_text or ""))
134
+ medium_plus = task_count >= MEDIUM_TASK_FLOOR
135
+
136
+ if medium_plus and design_text is None:
137
+ report.warn(
138
+ f"{task_count} tasks — design.md recommended for Medium+ features"
139
+ )
140
+
141
+ plan_text = "\n".join(filter(None, [spec_text, design_text]))
142
+ if medium_plus and plan_text and not VERIFY_HINT.search(plan_text):
143
+ report.warn(
144
+ "no verification or test strategy mention in spec/design — add verify plan before Execute"
145
+ )
146
+
124
147
  if design_text is not None and tasks_text:
125
148
  visible_design = visible_markdown(design_text).strip()
126
149
  if len(visible_design.splitlines()) < 5:
@@ -40,7 +40,18 @@ FEATURE_IN_PATH = re.compile(
40
40
  r"(?:^|/)features/(?P<name>[^/]+)/", re.IGNORECASE
41
41
  )
42
42
 
43
- STATUSES = ("candidate", "confirmed", "quarantined")
43
+ STATUSES = (
44
+ "observed",
45
+ "repeated",
46
+ "candidate",
47
+ "approved",
48
+ "graduated",
49
+ "deprecated",
50
+ "quarantined",
51
+ )
52
+ LEGACY_CONFIRMED = "confirmed"
53
+ GUIDANCE_STATUSES = frozenset({"approved", "graduated", LEGACY_CONFIRMED})
54
+ PROMOTION_CHAIN = ("observed", "repeated", "candidate", "approved")
44
55
 
45
56
 
46
57
  def fail(message: str, code: int = EXIT_FAILED) -> int:
@@ -54,6 +65,30 @@ def ok(message: str) -> int:
54
65
  return EXIT_OK
55
66
 
56
67
 
68
+ def normalize_status(raw: str | None) -> str:
69
+ value = str(raw or "").strip().lower()
70
+ if value == LEGACY_CONFIRMED:
71
+ return "approved"
72
+ return value
73
+
74
+
75
+ def is_guidance_status(status: str) -> bool:
76
+ return normalize_status(status) in GUIDANCE_STATUSES
77
+
78
+
79
+ def next_promotion_status(status: str) -> str | None:
80
+ normalized = normalize_status(status)
81
+ if normalized == LEGACY_CONFIRMED:
82
+ return "graduated"
83
+ try:
84
+ index = PROMOTION_CHAIN.index(normalized)
85
+ except ValueError:
86
+ return None
87
+ if index + 1 >= len(PROMOTION_CHAIN):
88
+ return None
89
+ return PROMOTION_CHAIN[index + 1]
90
+
91
+
57
92
  def normalize(text: str) -> str:
58
93
  """Casefold, strip accents and punctuation, collapse whitespace."""
59
94
 
@@ -173,32 +208,47 @@ def parse_iso_date(raw: str) -> date | None:
173
208
 
174
209
 
175
210
  def render_markdown(store: dict) -> str:
176
- confirmed = [item for item in store["lessons"] if item.get("status") == "confirmed"]
211
+ guidance = [
212
+ item
213
+ for item in store["lessons"]
214
+ if is_guidance_status(str(item.get("status") or ""))
215
+ and normalize_status(str(item.get("status") or "")) != "graduated"
216
+ ]
217
+ graduated = [
218
+ item
219
+ for item in store["lessons"]
220
+ if normalize_status(str(item.get("status") or "")) == "graduated"
221
+ ]
177
222
  lines = [
178
223
  "# Lessons Learned",
179
224
  "",
180
225
  "Generated by `lessons.py` from `.specs/lessons.json`. Do not edit this file.",
181
- "A clean PASS records nothing. Only **confirmed** lessons below are guidance.",
226
+ "A clean PASS records nothing. Only **approved** lessons below are guidance.",
227
+ "Graduated rules live in engineering standards — never auto-graduate.",
182
228
  "Inspect candidates with `python3 .specs/guardrails/scripts/lessons.py list --status all`.",
183
229
  "",
184
230
  ]
185
231
 
186
- if not confirmed:
232
+ if not guidance:
187
233
  lines.append("- none yet")
188
- lines.append("")
189
- return "\n".join(lines)
190
-
191
- for lesson in confirmed:
192
- lines.append(f"### {lesson.get('id', '?')}: {lesson.get('title', '')}")
193
- if lesson.get("trigger"):
194
- lines.append(f"- **Trigger**: {lesson['trigger']}")
195
- lines.append(f"- **Rule**: {lesson.get('rule', '')}")
196
- features = ", ".join(lesson.get("features") or [])
197
- if features:
198
- lines.append(f"- **Features**: {features}")
199
- lines.append(f"- **Source**: {lesson.get('source', '—')}")
200
- lines.append("")
201
-
234
+ else:
235
+ for lesson in guidance:
236
+ lines.append(f"### {lesson.get('id', '?')}: {lesson.get('title', '')}")
237
+ if lesson.get("trigger"):
238
+ lines.append(f"- **Trigger**: {lesson['trigger']}")
239
+ lines.append(f"- **Rule**: {lesson.get('rule', '')}")
240
+ features = ", ".join(lesson.get("features") or [])
241
+ if features:
242
+ lines.append(f"- **Features**: {features}")
243
+ lines.append(f"- **Source**: {lesson.get('source', '—')}")
244
+ lines.append("")
245
+
246
+ if graduated:
247
+ lines.extend(["## Graduated", ""])
248
+ for lesson in graduated:
249
+ lines.append(f"- {lesson.get('id', '?')}: {lesson.get('title', '')}")
250
+
251
+ lines.append("")
202
252
  return "\n".join(lines)
203
253
 
204
254
 
@@ -271,13 +321,13 @@ def cmd_add(args: argparse.Namespace) -> int:
271
321
  existing["updated"] = now
272
322
  existing["source"] = source_raw
273
323
  if (
274
- existing.get("status") == "candidate"
324
+ normalize_status(str(existing.get("status"))) == "candidate"
275
325
  and len(features) >= PROMOTE_AFTER_FEATURES
276
326
  ):
277
- existing["status"] = "confirmed"
327
+ existing["status"] = "approved"
278
328
  save_store(store)
279
329
  return ok(
280
- f"{existing['id']} promoted to confirmed "
330
+ f"{existing['id']} promoted to approved "
281
331
  f"(seen in {len(features)} features)"
282
332
  )
283
333
 
@@ -306,13 +356,24 @@ def cmd_list(args: argparse.Namespace) -> int:
306
356
  if isinstance(store, int):
307
357
  return store
308
358
 
309
- wanted = args.status or "confirmed"
359
+ wanted = args.status or "approved"
310
360
  if wanted == "all":
311
361
  rows = store["lessons"]
362
+ elif wanted == LEGACY_CONFIRMED:
363
+ rows = [
364
+ item
365
+ for item in store["lessons"]
366
+ if is_guidance_status(str(item.get("status") or ""))
367
+ ]
312
368
  elif wanted in STATUSES:
313
- rows = [item for item in store["lessons"] if item.get("status") == wanted]
369
+ rows = [
370
+ item for item in store["lessons"] if normalize_status(item.get("status")) == wanted
371
+ ]
314
372
  else:
315
- return fail(f"unknown status '{wanted}' - use {', '.join(STATUSES)} or all", EXIT_USAGE)
373
+ return fail(
374
+ f"unknown status '{wanted}' - use {', '.join(STATUSES)}, confirmed, or all",
375
+ EXIT_USAGE,
376
+ )
316
377
 
317
378
  print(f"[{GATE}] {len(rows)} {wanted} lesson(s)")
318
379
  for lesson in rows:
@@ -336,9 +397,9 @@ def cmd_penalize(args: argparse.Namespace) -> int:
336
397
  if not lesson:
337
398
  return fail(f"no such lesson: {lesson_id}")
338
399
 
339
- if lesson.get("status") != "confirmed":
400
+ if normalize_status(str(lesson.get("status"))) not in {"approved", LEGACY_CONFIRMED}:
340
401
  return fail(
341
- f"{lesson_id} is {lesson.get('status')} - only confirmed lessons can be penalized"
402
+ f"{lesson_id} is {lesson.get('status')} - only approved lessons can be penalized"
342
403
  )
343
404
 
344
405
  try:
@@ -392,18 +453,80 @@ def cmd_status(args: argparse.Namespace) -> int:
392
453
  return store
393
454
 
394
455
  counts = {status: 0 for status in STATUSES}
456
+ legacy_confirmed = 0
395
457
  for lesson in store["lessons"]:
396
- status = lesson.get("status")
458
+ raw_status = str(lesson.get("status") or "")
459
+ if raw_status == LEGACY_CONFIRMED:
460
+ legacy_confirmed += 1
461
+ continue
462
+ status = normalize_status(raw_status)
397
463
  if status in counts:
398
464
  counts[status] += 1
399
465
 
400
- total = sum(counts.values())
466
+ total = len(store["lessons"])
401
467
  print(f"[{GATE}] {total} lesson(s) in {STORE_PATH}")
402
468
  for status in STATUSES:
403
469
  print(f" {status:<12} {counts[status]}")
470
+ if legacy_confirmed:
471
+ print(f" {'confirmed (legacy)':<12} {legacy_confirmed}")
404
472
  return EXIT_OK
405
473
 
406
474
 
475
+ def cmd_promote(args: argparse.Namespace) -> int:
476
+ store = load_store()
477
+ if isinstance(store, int):
478
+ return store
479
+
480
+ lesson_id = (args.id or "").strip().upper()
481
+ lesson = next((item for item in store["lessons"] if item.get("id") == lesson_id), None)
482
+ if not lesson:
483
+ return fail(f"no such lesson: {lesson_id}")
484
+
485
+ next_status = next_promotion_status(str(lesson.get("status") or ""))
486
+ if not next_status:
487
+ return fail(
488
+ f"{lesson_id} is {lesson.get('status')} - no manual promotion step available"
489
+ )
490
+
491
+ lesson["status"] = next_status
492
+ lesson["updated"] = today_iso()
493
+ save_store(store)
494
+ return ok(f"{lesson_id} promoted to {next_status}")
495
+
496
+
497
+ def cmd_graduate(args: argparse.Namespace) -> int:
498
+ source = validate_source(args.source)
499
+ if isinstance(source, int):
500
+ return source
501
+
502
+ store = load_store()
503
+ if isinstance(store, int):
504
+ return store
505
+
506
+ lesson_id = (args.id or "").strip().upper()
507
+ lesson = next((item for item in store["lessons"] if item.get("id") == lesson_id), None)
508
+ if not lesson:
509
+ return fail(f"no such lesson: {lesson_id}")
510
+
511
+ status = normalize_status(str(lesson.get("status")))
512
+ if status not in {"approved", LEGACY_CONFIRMED}:
513
+ return fail(
514
+ f"{lesson_id} is {lesson.get('status')} - only approved lessons can graduate"
515
+ )
516
+
517
+ evidence = (args.evidence or "").strip()
518
+ if not evidence:
519
+ return fail("--evidence is required - graduation must cite why this is a house rule")
520
+
521
+ lesson["status"] = "graduated"
522
+ lesson["graduated_from"] = str(lesson.get("source") or "")
523
+ lesson["graduation_evidence"] = evidence
524
+ lesson["source"] = source[1]
525
+ lesson["updated"] = today_iso()
526
+ save_store(store)
527
+ return ok(f"{lesson_id} graduated — stop loading as active guidance")
528
+
529
+
407
530
  def build_parser() -> argparse.ArgumentParser:
408
531
  parser = argparse.ArgumentParser(description="Manage grounded lessons")
409
532
  sub = parser.add_subparsers(dest="command")
@@ -416,10 +539,20 @@ def build_parser() -> argparse.ArgumentParser:
416
539
  add.add_argument("--feature", default="")
417
540
  add.set_defaults(func=cmd_add)
418
541
 
419
- listing = sub.add_parser("list", help="list lessons (default: confirmed)")
420
- listing.add_argument("--status", default="confirmed")
542
+ listing = sub.add_parser("list", help="list lessons (default: approved)")
543
+ listing.add_argument("--status", default="approved")
421
544
  listing.set_defaults(func=cmd_list)
422
545
 
546
+ promote = sub.add_parser("promote", help="advance one step in the graduation lifecycle")
547
+ promote.add_argument("--id", required=True)
548
+ promote.set_defaults(func=cmd_promote)
549
+
550
+ graduate = sub.add_parser("graduate", help="graduate an approved lesson into a house rule")
551
+ graduate.add_argument("--id", required=True)
552
+ graduate.add_argument("--source", required=True, help="path to validation.md evidence")
553
+ graduate.add_argument("--evidence", required=True, help="why this rule is stable enough to graduate")
554
+ graduate.set_defaults(func=cmd_graduate)
555
+
423
556
  penalize = sub.add_parser("penalize", help="mark a confirmed lesson that failed to prevent a repeat")
424
557
  penalize.add_argument("--id", required=True)
425
558
  penalize.add_argument("--source", required=True)