@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 +1 -1
- package/index.js +90 -0
- package/lib/brownfield.js +23 -0
- package/lib/operational-loops.js +119 -0
- package/package.json +2 -2
- package/scripts/_project_config.py +30 -0
- package/scripts/loop_plan.py +37 -0
- package/scripts/validate_spec.py +31 -0
- package/skills/references/converge.md +1 -0
- package/skills/references/project-init.md +2 -0
- package/templates/config.yaml.example +7 -0
- package/templates/loops/catalog.json +151 -0
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) **
|
|
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": "
|
|
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"]
|
package/scripts/loop_plan.py
CHANGED
|
@@ -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))
|
package/scripts/validate_spec.py
CHANGED
|
@@ -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
|
+
}
|