@luizsantiago/spec-guardrails 4.8.0 → 5.0.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
@@ -21,7 +21,7 @@ You keep control: the agent proposes; you approve specs and tasks; push, merge,
21
21
 
22
22
  **Platform adapters.** The same kit installs into the skill tree your agent already reads — **Cursor** (`.cursor/skills/`), **Claude Code** (`.claude/skills/`), **GitHub Copilot** (`.github/skills/`), **OpenAI Codex** (`.codex/skills/`), plus root `AGENTS.md` for other tools. By default `install` detects one platform and writes **one** tree; use `--all-platforms` when the repo serves multiple agents. Existing trees are preserved when you switch IDEs.
23
23
 
24
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.8.x**
24
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **5.0.x**
25
25
 
26
26
  **Docs:** [Overview](docs/guide/Overview.md) · [Quick start](docs/guide/Quick-start.md) · [Full guide index](docs/guide/README.md)
27
27
 
package/index.js CHANGED
@@ -67,6 +67,14 @@ import {
67
67
  listPresets,
68
68
  loadPresetText,
69
69
  } from "./lib/presets.js";
70
+ import {
71
+ formatLoopListJson,
72
+ formatLoopPattern,
73
+ formatLoopRunBrief,
74
+ getLoopPattern,
75
+ listLoopPatterns,
76
+ loadLoopCatalog,
77
+ } from "./lib/operational-loops.js";
70
78
 
71
79
  const USAGE = `Usage: ${CLI_NAME} <command> [args]
72
80
 
@@ -85,6 +93,7 @@ Commands:
85
93
  [--domains a,b,c] Explicit domain slugs (overrides auto-detect)
86
94
  [--no-domains] Skip .specs/domains/ scaffolding
87
95
  [--no-project] Skip PROJECT.md generation
96
+ [--no-code-index] Skip code-index rebuild after init
88
97
  [--force] Overwrite generated project/domain/config files
89
98
  [--dry-run] Print scan results without writing files
90
99
  feature-init "<description>" Allocate NNN-slug feature, STATE, local branch (Tier 0)
@@ -188,6 +197,13 @@ Commands:
188
197
  analyze-artifacts [feature] Cross-artifact consistency before task approval
189
198
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
190
199
  loop-plan [tasks.md|feature] Next Execute wave — parallel groups + sub-agent hints
200
+ loop list Operational loop patterns (repo health, CI, triage)
201
+ [--json] Machine-readable catalog
202
+ loop show <pattern-id> Show one operational loop pattern
203
+ [--json] Machine-readable output
204
+ loop run <pattern-id> Print agent brief for an operational loop
205
+ [--dry-run] Same output (explicit no-op label)
206
+ [--json] Machine-readable brief
191
207
  [--json] Machine-readable plan for agents
192
208
  validate-traceability [feature] REQ → tasks → validation coverage chain
193
209
  validate-ship-surface [feature] Ship Surface + AI Surface when infra/AI paths in tasks
@@ -335,11 +351,83 @@ if (command === "--version" || command === "-v" || command === "version") {
335
351
  console.error(`❌ ${err.message}`);
336
352
  process.exit(1);
337
353
  }
354
+ } else if (command === "loop") {
355
+ try {
356
+ const sub = args[0];
357
+ const rest = args.slice(1);
358
+ const json = rest.includes("--json");
359
+ const dryRun = rest.includes("--dry-run");
360
+ const positional = rest.filter((arg) => !arg.startsWith("--"));
361
+
362
+ if (sub === "list") {
363
+ const patterns = await loadLoopCatalog();
364
+ if (json) {
365
+ console.log(JSON.stringify(formatLoopListJson(patterns), null, 2));
366
+ } else {
367
+ const ids = await listLoopPatterns();
368
+ console.log("Operational loop patterns:");
369
+ for (const id of ids) {
370
+ const pattern = patterns[id];
371
+ console.log(` ${id} — ${pattern.title} (${pattern.tier}, ${pattern.cadence})`);
372
+ }
373
+ console.log(`\nShow detail: ${CLI_NAME} loop show <pattern-id>`);
374
+ }
375
+ process.exit(0);
376
+ }
377
+
378
+ if (sub === "show") {
379
+ const id = positional[0];
380
+ if (!id) {
381
+ throw new Error("Usage: loop show <pattern-id> [--json]");
382
+ }
383
+ const pattern = await getLoopPattern(id);
384
+ if (!pattern) {
385
+ const available = (await listLoopPatterns()).join(", ");
386
+ throw new Error(`Unknown pattern "${id}". Available: ${available}`);
387
+ }
388
+ if (json) {
389
+ console.log(JSON.stringify({ id, ...pattern }, null, 2));
390
+ } else {
391
+ console.log(formatLoopPattern(id, pattern));
392
+ }
393
+ process.exit(0);
394
+ }
395
+
396
+ if (sub === "run") {
397
+ const id = positional[0];
398
+ if (!id) {
399
+ throw new Error("Usage: loop run <pattern-id> [--dry-run] [--json]");
400
+ }
401
+ const pattern = await getLoopPattern(id);
402
+ if (!pattern) {
403
+ const available = (await listLoopPatterns()).join(", ");
404
+ throw new Error(`Unknown pattern "${id}". Available: ${available}`);
405
+ }
406
+ if (json) {
407
+ console.log(
408
+ JSON.stringify(
409
+ { id, dry_run: dryRun, brief: formatLoopRunBrief(id, pattern, { dryRun }) },
410
+ null,
411
+ 2,
412
+ ),
413
+ );
414
+ } else {
415
+ console.log(formatLoopRunBrief(id, pattern, { dryRun }));
416
+ }
417
+ process.exit(0);
418
+ }
419
+
420
+ throw new Error("Usage: loop list | loop show <pattern-id> | loop run <pattern-id>");
421
+ } catch (err) {
422
+ console.error(`❌ ${err.message}`);
423
+ process.exit(1);
424
+ }
338
425
  } else if (command === "project-init") {
339
426
  try {
340
427
  const initOptions = {
341
428
  skipDomains: false,
342
429
  skipProject: false,
430
+ skipCodeIndex: false,
343
431
  force: false,
344
432
  dryRun: false,
345
433
  };
@@ -361,6 +449,8 @@ if (command === "--version" || command === "-v" || command === "version") {
361
449
  initOptions.skipDomains = true;
362
450
  } else if (arg === "--no-project") {
363
451
  initOptions.skipProject = true;
452
+ } else if (arg === "--no-code-index") {
453
+ initOptions.skipCodeIndex = true;
364
454
  } else if (arg === "--force") {
365
455
  initOptions.force = true;
366
456
  } else if (arg === "--dry-run") {
package/lib/brownfield.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { domainSpecStub } from "./delta-merge.js";
5
5
  import { ensureDir, readFileSafe, writeFileIfMissing, writeFileSafe } from "./fs-utils.js";
6
+ import { runGuardrailsScriptCapture } from "./gates.js";
6
7
  import { initGuardrailsMemory } from "./memory.js";
7
8
  import { initProjectConfig } from "./presets.js";
8
9
  import { assertSafeDomainSlug, slugifyDomain } from "./slug-utils.js";
@@ -508,6 +509,28 @@ export async function projectInit(options = {}) {
508
509
  planned.push(`.specs/config.yaml (replaced, preset: ${preset})`);
509
510
  }
510
511
 
512
+ if (!options.skipCodeIndex) {
513
+ const roots = stack.roots?.length ? stack.roots : ["src", "lib"];
514
+ try {
515
+ const { code, stdout, stderr } = await runGuardrailsScriptCapture(
516
+ "code-index",
517
+ ["rebuild", "--roots", roots.join(",")],
518
+ { cwd },
519
+ );
520
+ if (code === 0) {
521
+ planned.push(
522
+ `.specs/memory/code-index.json (rebuilt, roots: ${roots.join(", ")})`,
523
+ );
524
+ } else {
525
+ planned.push(
526
+ `code-index rebuild skipped (exit ${code})${stderr ? `: ${stderr.trim().slice(0, 120)}` : ""}`,
527
+ );
528
+ }
529
+ } catch {
530
+ planned.push("code-index rebuild skipped (Python gates unavailable)");
531
+ }
532
+ }
533
+
511
534
  return {
512
535
  dryRun: false,
513
536
  repoName,
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { readFileSafe } from "./fs-utils.js";
6
+ import { NPX } from "./constants.js";
7
+
8
+ const PACKAGE_ROOT = path.resolve(
9
+ path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
10
+ );
11
+
12
+ export const LOOPS_CATALOG_PATH = path.join(
13
+ PACKAGE_ROOT,
14
+ "templates/loops/catalog.json",
15
+ );
16
+
17
+ /**
18
+ * @typedef {object} LoopPattern
19
+ * @property {string} title
20
+ * @property {string} tier
21
+ * @property {string} cadence
22
+ * @property {string} posture
23
+ * @property {string} description
24
+ * @property {string[]} constraints
25
+ * @property {string[]} stop_rules
26
+ * @property {string[]} suggested_steps
27
+ */
28
+
29
+ /**
30
+ * @returns {Promise<Record<string, LoopPattern>>}
31
+ */
32
+ export async function loadLoopCatalog() {
33
+ const raw = await readFileSafe(LOOPS_CATALOG_PATH);
34
+ const parsed = JSON.parse(raw);
35
+ return parsed.patterns ?? {};
36
+ }
37
+
38
+ /**
39
+ * @returns {Promise<string[]>}
40
+ */
41
+ export async function listLoopPatterns() {
42
+ const patterns = await loadLoopCatalog();
43
+ return Object.keys(patterns).sort();
44
+ }
45
+
46
+ /**
47
+ * @param {string} id
48
+ * @returns {Promise<LoopPattern | null>}
49
+ */
50
+ export async function getLoopPattern(id) {
51
+ const patterns = await loadLoopCatalog();
52
+ return patterns[id] ?? null;
53
+ }
54
+
55
+ /**
56
+ * @param {string} id
57
+ * @param {LoopPattern} pattern
58
+ * @returns {string}
59
+ */
60
+ export function formatLoopPattern(id, pattern) {
61
+ const lines = [
62
+ `${pattern.title} (${id})`,
63
+ `Tier: ${pattern.tier} · Cadence: ${pattern.cadence}`,
64
+ "",
65
+ pattern.description,
66
+ "",
67
+ `Posture: ${pattern.posture}`,
68
+ "",
69
+ "Constraints:",
70
+ ...pattern.constraints.map((item) => ` - ${item}`),
71
+ "",
72
+ "Stop rules:",
73
+ ...pattern.stop_rules.map((item) => ` - ${item}`),
74
+ "",
75
+ "Suggested steps:",
76
+ ...pattern.suggested_steps.map((item) => ` - ${item}`),
77
+ ];
78
+ return lines.join("\n");
79
+ }
80
+
81
+ /**
82
+ * @param {string} id
83
+ * @param {LoopPattern} pattern
84
+ * @param {{ dryRun?: boolean }} [options]
85
+ * @returns {string}
86
+ */
87
+ export function formatLoopRunBrief(id, pattern, options = {}) {
88
+ const prefix = options.dryRun ? "[dry-run] " : "";
89
+ const lines = [
90
+ `${prefix}Operational loop: ${pattern.title} (${id})`,
91
+ "",
92
+ "This command prints a structured brief for the agent — it does not run automation.",
93
+ "",
94
+ formatLoopPattern(id, pattern),
95
+ "",
96
+ "Harness hooks:",
97
+ ` ${NPX("doctor")}`,
98
+ ` ${NPX(`classify-change "${pattern.title}"`)}`,
99
+ "",
100
+ "Feature work still uses: feature-init → Specify → Tasks → loop-plan → Verify.",
101
+ "See docs/guide/loop-patterns.md for combining operational and feature loops.",
102
+ ];
103
+ return lines.join("\n");
104
+ }
105
+
106
+ /**
107
+ * @param {Record<string, LoopPattern>} patterns
108
+ * @returns {object}
109
+ */
110
+ export function formatLoopListJson(patterns) {
111
+ return {
112
+ patterns: Object.entries(patterns).map(([id, pattern]) => ({
113
+ id,
114
+ title: pattern.title,
115
+ tier: pattern.tier,
116
+ cadence: pattern.cadence,
117
+ })),
118
+ };
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "4.8.0",
3
+ "version": "5.0.0",
4
4
  "description": "Governed spec-driven development for AI coding agents: spec-first workflow, optional Python gates (Brakes), repo memory in .specs/, and an optional python-platform preset when Python features touch deploy/infra and/or AI paths.",
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_platform_detect.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_feature_overview.test.js test/test_feature_pr_body.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_cleanup.test.js test/test_sandbox_policy.test.js test/test_req_analysis.test.js",
15
+ "test:node": "node --test test/install.test.js test/test_platform_detect.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_feature_overview.test.js test/test_feature_pr_body.test.js test/test_operational_loops.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_cleanup.test.js test/test_sandbox_policy.test.js test/test_req_analysis.test.js",
16
16
  "test:gates": "node test/run-gate-tests.mjs",
17
17
  "prepublishOnly": "npm test"
18
18
  },
@@ -93,6 +93,11 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
93
93
  "require_brief": False,
94
94
  "require_brief_complex": True,
95
95
  "require_nfr_complex": "warn",
96
+ "require_test_plan_complex": "warn",
97
+ },
98
+ "converge": {
99
+ "every_n_tasks": 5,
100
+ "mode": "suggest",
96
101
  },
97
102
  }
98
103
 
@@ -144,6 +149,12 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
144
149
  index += 1
145
150
  continue
146
151
 
152
+ if stripped == "converge:":
153
+ section = "converge"
154
+ section_indent = indent
155
+ index += 1
156
+ continue
157
+
147
158
  if section and indent <= section_indent and not stripped.endswith(":"):
148
159
  section = None
149
160
 
@@ -189,6 +200,19 @@ def load_project_config(cwd: Path | str | None = None) -> dict:
189
200
  config["elicitation"]["require_nfr_complex"] = str(
190
201
  _parse_scalar(match.group(1))
191
202
  ).lower()
203
+ match = re.match(r"^require_test_plan_complex:\s*(.+)$", stripped)
204
+ if match:
205
+ config["elicitation"]["require_test_plan_complex"] = str(
206
+ _parse_scalar(match.group(1))
207
+ ).lower()
208
+
209
+ if section == "converge":
210
+ match = re.match(r"^every_n_tasks:\s*(.+)$", stripped)
211
+ if match:
212
+ config["converge"]["every_n_tasks"] = int(_parse_scalar(match.group(1)))
213
+ match = re.match(r"^mode:\s*(.+)$", stripped)
214
+ if match:
215
+ config["converge"]["mode"] = str(_parse_scalar(match.group(1))).lower()
192
216
 
193
217
  index += 1
194
218
 
@@ -205,3 +229,9 @@ def load_elicitation_config(cwd: Path | str | None = None) -> dict:
205
229
  """Return elicitation policy with defaults for validate_spec and req-analysis."""
206
230
 
207
231
  return load_project_config(cwd)["elicitation"]
232
+
233
+
234
+ def load_converge_config(cwd: Path | str | None = None) -> dict:
235
+ """Return converge policy for loop-plan hints."""
236
+
237
+ return load_project_config(cwd)["converge"]
@@ -24,6 +24,7 @@ import sys
24
24
  from pathlib import Path
25
25
 
26
26
  from _common import resolve_artifact, visible_markdown
27
+ from _project_config import load_converge_config
27
28
  from validate_tasks import parse_dependencies, parse_fields, parse_files, split_tasks
28
29
 
29
30
  GATE = "loop-plan"
@@ -132,9 +133,39 @@ def build_plan(text: str, *, task_graph_text: str | None = None) -> dict:
132
133
  "blocked": blocked,
133
134
  "all_done": not incomplete,
134
135
  "recommend_sub_agents": any(group["sub_agents"] for group in groups),
136
+ "completed_count": len(completed),
135
137
  }
136
138
 
137
139
 
140
+ def apply_converge_hint(plan: dict, tasks_path: Path) -> None:
141
+ """Attach /converge suggestion when completed tasks hit configured threshold."""
142
+
143
+ feature_dir = tasks_path.parent
144
+ if feature_dir.parent.name != "features":
145
+ return
146
+
147
+ root = feature_dir.parent.parent.parent
148
+ policy = load_converge_config(root)
149
+ mode = str(policy.get("mode", "suggest")).lower()
150
+ every = int(policy.get("every_n_tasks", 5) or 0)
151
+
152
+ if mode == "off" or every <= 0:
153
+ return
154
+
155
+ completed = plan.get("completed_count", len(plan.get("completed", [])))
156
+ if completed <= 0 or completed % every != 0:
157
+ return
158
+
159
+ plan["converge_suggest"] = True
160
+ plan["converge_mode"] = mode
161
+ plan["converge_hint"] = (
162
+ f"{completed} tasks complete — run /converge: analyze-artifacts, append gaps, "
163
+ "feature-overview --write"
164
+ )
165
+ if mode == "warn":
166
+ plan["converge_warning"] = True
167
+
168
+
138
169
  def format_plan(plan: dict) -> str:
139
170
  lines: list[str] = []
140
171
 
@@ -179,6 +210,11 @@ def format_plan(plan: dict) -> str:
179
210
  waiting = ", ".join(item["waiting_on"])
180
211
  lines.append(f" {item['id']}: after {waiting}")
181
212
 
213
+ if plan.get("converge_hint"):
214
+ lines.append("")
215
+ prefix = "Converge (warn): " if plan.get("converge_warning") else "Converge (suggest): "
216
+ lines.append(prefix + plan["converge_hint"])
217
+
182
218
  return "\n".join(lines)
183
219
 
184
220
 
@@ -200,6 +236,7 @@ def main(argv: list[str] | None = None) -> int:
200
236
  graph_path.read_text(encoding="utf-8") if graph_path.is_file() else None
201
237
  )
202
238
  plan = build_plan(text, task_graph_text=graph_text)
239
+ apply_converge_hint(plan, path)
203
240
 
204
241
  if args.json:
205
242
  print(json.dumps(plan, indent=2))
@@ -87,6 +87,10 @@ NFR_HEADING = re.compile(
87
87
  r"^(?P<level>#{2,6})\s*Non-Functional Requirements\b",
88
88
  re.MULTILINE | re.IGNORECASE,
89
89
  )
90
+ TEST_PLAN_HEADING = re.compile(
91
+ r"^(?P<level>#{2,6})\s*Test Plan\b",
92
+ re.MULTILINE | re.IGNORECASE,
93
+ )
90
94
  FEATURE_BRIEFS = Path(".specs/project/feature-briefs")
91
95
  PROJECT_BRIEF = Path(".specs/project/requirements-brief.md")
92
96
  COMPLEX_TASK_FLOOR = 10
@@ -184,6 +188,32 @@ def validate_nfr_section(report: Report, visible: str, feature_dir: Path) -> Non
184
188
  report.warn(message)
185
189
 
186
190
 
191
+ def validate_test_plan_section(report: Report, visible: str, feature_dir: Path) -> None:
192
+ root = feature_dir.parent.parent.parent
193
+ policy = load_elicitation_config(root)
194
+ mode = str(policy.get("require_test_plan_complex", "warn")).lower()
195
+ if mode == "off" or not is_complex_tier(feature_dir):
196
+ return
197
+
198
+ body = section_body(visible, TEST_PLAN_HEADING)
199
+ if body and body.strip() and not re.fullmatch(
200
+ r"\s*[-*]?\s*(none|n/a)\s*",
201
+ body.strip(),
202
+ re.IGNORECASE,
203
+ ):
204
+ report.ok("Test Plan section present")
205
+ return
206
+
207
+ message = (
208
+ "Complex-tier feature missing ## Test Plan "
209
+ "(acceptance scenarios per REQ before tasks)"
210
+ )
211
+ if mode == "error":
212
+ report.error(message)
213
+ else:
214
+ report.warn(message)
215
+
216
+
187
217
  def is_delta_spec(text: str) -> bool:
188
218
  visible = visible_markdown(text)
189
219
  return any(has_section(visible, heading) for heading, _ in DELTA_SECTIONS)
@@ -437,6 +467,7 @@ def build_report(target: str, text: str, feature_dir: Path | None = None) -> Rep
437
467
 
438
468
  if feature_dir is not None and not delta:
439
469
  validate_nfr_section(report, visible, feature_dir)
470
+ validate_test_plan_section(report, visible, feature_dir)
440
471
 
441
472
  for malformed in MALFORMED_ID.finditer(visible):
442
473
  raw = malformed.group(0).lstrip("# ").strip()
@@ -41,6 +41,7 @@ Updated `tasks.md` with new tasks for uncovered work (append only — do not rew
41
41
  - Never weaken tests to match partial implementation.
42
42
  - New tasks need Requirement, Files, Depends on, Tests, Gate, Done when.
43
43
  - Re-run `validate_tasks.py` after editing tasks.
44
+ - When `loop-plan --json` shows `converge_suggest`, run this procedure before more Execute.
44
45
 
45
46
  ## Next
46
47
 
@@ -25,6 +25,7 @@ Map an existing codebase into `.specs/` project memory before the first `/specif
25
25
  - `.specs/project/ROADMAP.md` — planned domain drafting items
26
26
  - `.specs/domains/[domain]/spec.md` — brownfield stubs (one per detected/manual domain)
27
27
  - `.specs/config.yaml` — from auto-detected or chosen preset (when missing)
28
+ - `.specs/memory/code-index.json` — shallow symbol map (unless `--no-code-index`)
28
29
 
29
30
  ## Procedure
30
31
 
@@ -47,6 +48,7 @@ Map an existing codebase into `.specs/` project memory before the first `/specif
47
48
  | `--domains a,b` | Manual domain list (overrides auto-detect) |
48
49
  | `--no-domains` | Skip domain folder scaffolding |
49
50
  | `--no-project` | Skip `PROJECT.md` |
51
+ | `--no-code-index` | Skip `code-index rebuild` after init |
50
52
  | `--force` | Overwrite generated project/domain/config files |
51
53
 
52
54
  ## Rules
@@ -77,6 +77,13 @@ elicitation:
77
77
  require_brief_complex: true
78
78
  # warn | error | off — NFR section on Complex-tier full specs
79
79
  require_nfr_complex: warn
80
+ # warn | error | off — Test Plan section on Complex-tier full specs
81
+ require_test_plan_complex: warn
82
+
83
+ # Converge hints during Execute (loop-plan --json)
84
+ converge:
85
+ every_n_tasks: 5
86
+ mode: suggest # suggest | warn | off
80
87
 
81
88
  # Soft OS sandbox (optional — policy, not containers; enforced via sandbox CLI)
82
89
  sandbox:
@@ -0,0 +1,151 @@
1
+ {
2
+ "patterns": {
3
+ "daily-triage": {
4
+ "title": "Daily triage",
5
+ "tier": "quick",
6
+ "cadence": "daily",
7
+ "posture": "Read-only scan → short report; no feature folder unless a fix ships",
8
+ "description": "Inbox, issues, and open threads pile up — produce a prioritized snapshot without coding.",
9
+ "constraints": [
10
+ "One pattern per session — no mixing with dependency sweeps",
11
+ "Read-only until owner approves a fix scope",
12
+ "Cap output to one screen of findings"
13
+ ],
14
+ "stop_rules": [
15
+ "Stop after the report is written",
16
+ "Escalate to feature-init only when owner picks an item to ship"
17
+ ],
18
+ "suggested_steps": [
19
+ "Run doctor for readiness baseline",
20
+ "Scan open issues / PR comments / failing CI badges",
21
+ "classify-change on any fix candidate before Specify"
22
+ ]
23
+ },
24
+ "pr-babysitter": {
25
+ "title": "PR babysitter",
26
+ "tier": "medium",
27
+ "cadence": "per PR",
28
+ "posture": "Watch CI on open PRs; propose minimal fixes",
29
+ "description": "Open PRs waiting on CI — diagnose failures and suggest the smallest fix.",
30
+ "constraints": [
31
+ "One PR at a time",
32
+ "No scope expansion beyond the PR intent",
33
+ "Prefer Medium tier with tasks when the fix touches 3+ files"
34
+ ],
35
+ "stop_rules": [
36
+ "Stop when CI is green or owner defers",
37
+ "Never force-push main"
38
+ ],
39
+ "suggested_steps": [
40
+ "List open PRs and failing checks",
41
+ "Reproduce the failure locally",
42
+ "If fix needs spec/tasks → feature-init or append tasks on existing feature"
43
+ ]
44
+ },
45
+ "ci-sweeper": {
46
+ "title": "CI sweeper",
47
+ "tier": "medium",
48
+ "cadence": "on red main",
49
+ "posture": "Cautious fixes — one failure at a time",
50
+ "description": "Main branch or default pipeline is red or flaky — restore green before new features.",
51
+ "constraints": [
52
+ "Fix the first failing job only",
53
+ "Record discrimination sensor notes when behavior changes",
54
+ "No drive-by refactors"
55
+ ],
56
+ "stop_rules": [
57
+ "Stop when quality.checks pass",
58
+ "Hand off to feature loop if root cause needs product spec"
59
+ ],
60
+ "suggested_steps": [
61
+ "Run quality-checks or project test command",
62
+ "Isolate the failing step from CI logs",
63
+ "check-suppressions before any commit"
64
+ ]
65
+ },
66
+ "dependency-sweeper": {
67
+ "title": "Dependency sweeper",
68
+ "tier": "simple",
69
+ "cadence": "weekly",
70
+ "posture": "Patch-only bumps; run full test suite",
71
+ "description": "Outdated packages — apply patch/minor bumps with tests.",
72
+ "constraints": [
73
+ "Patch (and agreed minor) only — no major without owner",
74
+ "One ecosystem at a time (npm OR pip, not both)",
75
+ "Lockfile commits are atomic"
76
+ ],
77
+ "stop_rules": [
78
+ "Stop after one bump wave passes tests",
79
+ "Defer breaking upgrades to a Specify feature"
80
+ ],
81
+ "suggested_steps": [
82
+ "Audit outdated deps (npm outdated / pip list --outdated)",
83
+ "Bump one package or group",
84
+ "Run project test command + check-commit"
85
+ ]
86
+ },
87
+ "changelog-drafter": {
88
+ "title": "Changelog drafter",
89
+ "tier": "quick",
90
+ "cadence": "pre-release",
91
+ "posture": "Summarize merged work — docs only",
92
+ "description": "Before release — draft CHANGELOG / release notes from merged PRs.",
93
+ "constraints": [
94
+ "No code changes",
95
+ "Link PR numbers and owner-facing outcomes",
96
+ "Match semver section in CHANGELOG"
97
+ ],
98
+ "stop_rules": [
99
+ "Stop when draft is ready for owner review",
100
+ "Owner publishes — agent never npm publish"
101
+ ],
102
+ "suggested_steps": [
103
+ "git log since last tag",
104
+ "Group by feat/fix/docs",
105
+ "Align with docs/CHANGELOG.md format"
106
+ ]
107
+ },
108
+ "post-merge-cleanup": {
109
+ "title": "Post-merge cleanup",
110
+ "tier": "quick",
111
+ "cadence": "after large merge",
112
+ "posture": "Dead code, TODOs, format — stay inside merged scope",
113
+ "description": "After a large merge — hygiene pass without new features.",
114
+ "constraints": [
115
+ "Only files touched by the merge",
116
+ "No behavior changes without tests",
117
+ "Quick tier unless scope grows"
118
+ ],
119
+ "stop_rules": [
120
+ "Stop after validate-quick or owner says done",
121
+ "Promote to Medium if 4+ files need logic changes"
122
+ ],
123
+ "suggested_steps": [
124
+ "Review merge diff for stray TODOs and debug logs",
125
+ "Run linter/format on touched paths",
126
+ "check-commit with chore scope"
127
+ ]
128
+ },
129
+ "issue-triage": {
130
+ "title": "Issue triage",
131
+ "tier": "quick",
132
+ "cadence": "backlog grooming",
133
+ "posture": "Label, dedupe, propose — no code",
134
+ "description": "Backlog grooming — organize issues without implementing.",
135
+ "constraints": [
136
+ "No code commits",
137
+ "Propose feature slugs for ROADMAP candidates",
138
+ "Use Explore, not Execute"
139
+ ],
140
+ "stop_rules": [
141
+ "Stop when triage report is written",
142
+ "Elicit only when owner picks an issue to specify"
143
+ ],
144
+ "suggested_steps": [
145
+ "Dedupe and label open issues",
146
+ "classify-change per candidate",
147
+ "Append ROADMAP.md candidates (owner approves)"
148
+ ]
149
+ }
150
+ }
151
+ }