@luizsantiago/spec-guardrails 3.4.0 → 3.5.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 +2 -1
- package/index.js +161 -0
- package/lib/constants.js +1 -0
- package/lib/solution-exploration.js +528 -0
- package/package.json +2 -2
- package/skills/agent-architecture.md +4 -0
- package/skills/references/design.md +1 -0
- package/skills/references/solution-exploration.md +86 -0
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.
|
|
14
|
+
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.5.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.5.x** | Solution exploration (`solution-explore`) — compare candidates from approved spec |
|
|
220
221
|
| **3.4.x** | Contextual guards (`context-guard`); FTS memory search (`memory-search`) |
|
|
221
222
|
| **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
|
|
222
223
|
| **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
|
package/index.js
CHANGED
|
@@ -35,6 +35,16 @@ import {
|
|
|
35
35
|
listWorkspaces,
|
|
36
36
|
prepareWorkspaces,
|
|
37
37
|
} from "./lib/workspace-isolation.js";
|
|
38
|
+
import {
|
|
39
|
+
formatExplorationInit,
|
|
40
|
+
formatExplorationStatus,
|
|
41
|
+
formatExplorationValidation,
|
|
42
|
+
getExplorationStatus,
|
|
43
|
+
initExploration,
|
|
44
|
+
normalizeCandidateId,
|
|
45
|
+
recordExplorationDecision,
|
|
46
|
+
validateExplorationArtifact,
|
|
47
|
+
} from "./lib/solution-exploration.js";
|
|
38
48
|
import {
|
|
39
49
|
initProjectConfig,
|
|
40
50
|
listPresets,
|
|
@@ -108,6 +118,22 @@ Commands:
|
|
|
108
118
|
context-guard check-complete Contextual guard before claiming feature done
|
|
109
119
|
[feature] Feature id (default: active in STATE)
|
|
110
120
|
[--json] Machine-readable output
|
|
121
|
+
solution-explore init <feature> Start solution exploration from approved spec
|
|
122
|
+
--candidates A,B[,C] Candidate ids (required, at least two)
|
|
123
|
+
[--labels "a,b,c"] Optional labels aligned to candidate ids
|
|
124
|
+
[--base-ref HEAD] Base ref for candidate worktrees
|
|
125
|
+
[--force] Replace existing exploration.md
|
|
126
|
+
[--json] Machine-readable output
|
|
127
|
+
solution-explore status [feature] Show exploration candidates and decision state
|
|
128
|
+
[--json] Machine-readable output
|
|
129
|
+
solution-explore validate [feature] Gate: comparison matrix complete before select
|
|
130
|
+
[--json] Machine-readable output
|
|
131
|
+
solution-explore select <feature> Record exploration decision
|
|
132
|
+
--candidate A Selected candidate id (required)
|
|
133
|
+
[--merge B] Optional secondary candidate to merge from
|
|
134
|
+
--rationale "…" Why this candidate won (required)
|
|
135
|
+
[--cleanup] Remove non-selected candidate worktrees
|
|
136
|
+
[--json] Machine-readable output
|
|
111
137
|
validate-spec [spec.md|feature] Closure gate for a feature spec
|
|
112
138
|
analyze-artifacts [feature] Cross-artifact consistency before task approval
|
|
113
139
|
validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
|
|
@@ -661,6 +687,141 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
661
687
|
console.error(`❌ ${err.message}`);
|
|
662
688
|
process.exit(1);
|
|
663
689
|
}
|
|
690
|
+
} else if (command === "solution-explore") {
|
|
691
|
+
try {
|
|
692
|
+
const sub = args[0];
|
|
693
|
+
let json = false;
|
|
694
|
+
const rest = [];
|
|
695
|
+
|
|
696
|
+
for (let i = 1; i < args.length; i++) {
|
|
697
|
+
if (args[i] === "--json") {
|
|
698
|
+
json = true;
|
|
699
|
+
} else {
|
|
700
|
+
rest.push(args[i]);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const cwd = process.cwd();
|
|
705
|
+
|
|
706
|
+
if (sub === "init") {
|
|
707
|
+
let candidatesRaw = "";
|
|
708
|
+
let labelsRaw = "";
|
|
709
|
+
let baseRef = "HEAD";
|
|
710
|
+
let force = false;
|
|
711
|
+
const positional = [];
|
|
712
|
+
|
|
713
|
+
for (let i = 0; i < rest.length; i++) {
|
|
714
|
+
const arg = rest[i];
|
|
715
|
+
if (arg === "--candidates") {
|
|
716
|
+
candidatesRaw = rest[++i] ?? "";
|
|
717
|
+
} else if (arg === "--labels") {
|
|
718
|
+
labelsRaw = rest[++i] ?? "";
|
|
719
|
+
} else if (arg === "--base-ref") {
|
|
720
|
+
baseRef = rest[++i];
|
|
721
|
+
if (!baseRef) {
|
|
722
|
+
throw new Error("--base-ref requires a git ref");
|
|
723
|
+
}
|
|
724
|
+
} else if (arg === "--force") {
|
|
725
|
+
force = true;
|
|
726
|
+
} else {
|
|
727
|
+
positional.push(arg);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const featureId = positional[0];
|
|
732
|
+
if (!featureId || !candidatesRaw) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
"Usage: solution-explore init <feature> --candidates A,B [--labels \"a,b\"] [--base-ref HEAD] [--force]",
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const ids = candidatesRaw.split(",").map((item) => item.trim()).filter(Boolean);
|
|
739
|
+
const labels = labelsRaw
|
|
740
|
+
? labelsRaw.split(",").map((item) => item.trim())
|
|
741
|
+
: [];
|
|
742
|
+
const candidateSpecs = ids.map((id, index) => ({
|
|
743
|
+
id: normalizeCandidateId(id),
|
|
744
|
+
label: labels[index] || undefined,
|
|
745
|
+
}));
|
|
746
|
+
|
|
747
|
+
const result = await initExploration(cwd, featureId, candidateSpecs, { baseRef, force });
|
|
748
|
+
process.stdout.write(formatExplorationInit(result, { json }));
|
|
749
|
+
|
|
750
|
+
if (result.workspaces.some((item) => item.status === "failed")) {
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
} else if (sub === "status") {
|
|
754
|
+
const featureId = rest[0];
|
|
755
|
+
const status = await getExplorationStatus(cwd, featureId);
|
|
756
|
+
process.stdout.write(formatExplorationStatus(status, { json }));
|
|
757
|
+
} else if (sub === "validate") {
|
|
758
|
+
const featureId = rest[0];
|
|
759
|
+
if (!featureId) {
|
|
760
|
+
throw new Error("Usage: solution-explore validate <feature>");
|
|
761
|
+
}
|
|
762
|
+
const result = await validateExplorationArtifact(cwd, featureId);
|
|
763
|
+
process.stdout.write(formatExplorationValidation(result, { json }));
|
|
764
|
+
if (!result.ok) {
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
} else if (sub === "select") {
|
|
768
|
+
let selected = "";
|
|
769
|
+
let mergedFrom = "";
|
|
770
|
+
let rationale = "";
|
|
771
|
+
let cleanup = false;
|
|
772
|
+
const positional = [];
|
|
773
|
+
|
|
774
|
+
for (let i = 0; i < rest.length; i++) {
|
|
775
|
+
const arg = rest[i];
|
|
776
|
+
if (arg === "--candidate") {
|
|
777
|
+
selected = rest[++i] ?? "";
|
|
778
|
+
} else if (arg === "--merge") {
|
|
779
|
+
mergedFrom = rest[++i] ?? "";
|
|
780
|
+
} else if (arg === "--rationale") {
|
|
781
|
+
rationale = rest[++i] ?? "";
|
|
782
|
+
} else if (arg === "--cleanup") {
|
|
783
|
+
cleanup = true;
|
|
784
|
+
} else {
|
|
785
|
+
positional.push(arg);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const featureId = positional[0];
|
|
790
|
+
if (!featureId || !selected || !rationale) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
'Usage: solution-explore select <feature> --candidate A --rationale "…" [--merge B] [--cleanup]',
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const result = await recordExplorationDecision(cwd, featureId, {
|
|
797
|
+
selected,
|
|
798
|
+
mergedFrom: mergedFrom || null,
|
|
799
|
+
rationale,
|
|
800
|
+
cleanup,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
if (json) {
|
|
804
|
+
console.log(JSON.stringify(result, null, 2));
|
|
805
|
+
} else {
|
|
806
|
+
console.log(`Recorded decision for ${result.featureId}: ${result.selected}`);
|
|
807
|
+
if (result.mergedFrom) {
|
|
808
|
+
console.log(` Merged from: ${result.mergedFrom}`);
|
|
809
|
+
}
|
|
810
|
+
if (result.cleanup?.length) {
|
|
811
|
+
for (const item of result.cleanup) {
|
|
812
|
+
console.log(` Cleanup ${path.basename(item.path)}: ${item.status}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
throw new Error(
|
|
818
|
+
"Usage: solution-explore init | status | validate | select",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.error(`❌ ${err.message}`);
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
664
825
|
} else if (command === "classify-change") {
|
|
665
826
|
try {
|
|
666
827
|
let json = false;
|
package/lib/constants.js
CHANGED
|
@@ -77,6 +77,7 @@ export const SKILL_ASSETS = [
|
|
|
77
77
|
/** @type {{ file: string, remotePath: string }[]} */
|
|
78
78
|
export const REFERENCE_ASSETS = [
|
|
79
79
|
{ file: "explore.md", remotePath: "skills/references/explore.md" },
|
|
80
|
+
{ file: "solution-exploration.md", remotePath: "skills/references/solution-exploration.md" },
|
|
80
81
|
{ file: "project-init.md", remotePath: "skills/references/project-init.md" },
|
|
81
82
|
{ file: "constitution.md", remotePath: "skills/references/constitution.md" },
|
|
82
83
|
{ file: "specify.md", remotePath: "skills/references/specify.md" },
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readFileSafe } from "./fs-utils.js";
|
|
5
|
+
import { featureDir, resolveFeatureId } from "./specs-utils.js";
|
|
6
|
+
import {
|
|
7
|
+
cleanupWorkspaces,
|
|
8
|
+
listWorkspaces,
|
|
9
|
+
prepareWorkspaces,
|
|
10
|
+
workspacePath,
|
|
11
|
+
WORKSPACES_ROOT,
|
|
12
|
+
} from "./workspace-isolation.js";
|
|
13
|
+
|
|
14
|
+
export const EXPLORATION_FILENAME = "exploration.md";
|
|
15
|
+
|
|
16
|
+
/** @type {readonly string[]} */
|
|
17
|
+
export const COMPARISON_CRITERIA = [
|
|
18
|
+
"Spec compliance",
|
|
19
|
+
"Test results",
|
|
20
|
+
"Complexity",
|
|
21
|
+
"Maintainability",
|
|
22
|
+
"Performance",
|
|
23
|
+
"Risk",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} candidateId
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function candidateWorkspaceId(candidateId) {
|
|
31
|
+
return `candidate-${candidateId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} raw
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeCandidateId(raw) {
|
|
39
|
+
const cleaned = raw.trim();
|
|
40
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,15}$/.test(cleaned)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Invalid candidate id "${raw}" (use 1–16 alphanumeric characters, hyphen, or underscore)`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return cleaned;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} featureId
|
|
50
|
+
* @param {Array<{ id: string, label: string }>} candidates
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function buildExplorationMarkdown(featureId, candidates, cwd) {
|
|
55
|
+
const candidateRows = candidates
|
|
56
|
+
.map(({ id, label }) => {
|
|
57
|
+
const workspace = path
|
|
58
|
+
.join(WORKSPACES_ROOT, featureId, candidateWorkspaceId(id))
|
|
59
|
+
.replace(/\\/g, "/");
|
|
60
|
+
return `| ${id} | ${label} | ${workspace} | exploring |`;
|
|
61
|
+
})
|
|
62
|
+
.join("\n");
|
|
63
|
+
|
|
64
|
+
const headerCols = candidates.map(({ id }) => id).join(" | ");
|
|
65
|
+
const comparisonRows = COMPARISON_CRITERIA.map(
|
|
66
|
+
(criterion) => `| ${criterion} | ${candidates.map(() => "").join(" | ")} |`,
|
|
67
|
+
).join("\n");
|
|
68
|
+
|
|
69
|
+
return `# Solution Exploration: ${featureId}
|
|
70
|
+
|
|
71
|
+
## Status
|
|
72
|
+
|
|
73
|
+
exploring
|
|
74
|
+
|
|
75
|
+
## Candidates
|
|
76
|
+
|
|
77
|
+
| Id | Label | Workspace | Status |
|
|
78
|
+
| --- | --- | --- | --- |
|
|
79
|
+
${candidateRows}
|
|
80
|
+
|
|
81
|
+
## Comparison
|
|
82
|
+
|
|
83
|
+
| Criterion | ${headerCols} |
|
|
84
|
+
| --- | ${candidates.map(() => "---").join(" | ")} |
|
|
85
|
+
${comparisonRows}
|
|
86
|
+
|
|
87
|
+
## Decision
|
|
88
|
+
|
|
89
|
+
- **Selected**: (pending)
|
|
90
|
+
- **Merged from**: —
|
|
91
|
+
- **Rationale**: —
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} line
|
|
97
|
+
* @returns {string[] | null}
|
|
98
|
+
*/
|
|
99
|
+
function parseTableRow(line) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed.startsWith("|") || trimmed.includes("---")) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const cells = trimmed
|
|
105
|
+
.slice(1, trimmed.endsWith("|") ? -1 : undefined)
|
|
106
|
+
.split("|")
|
|
107
|
+
.map((cell) => cell.trim());
|
|
108
|
+
return cells.length ? cells : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} content
|
|
113
|
+
* @param {string} heading
|
|
114
|
+
* @returns {string[]}
|
|
115
|
+
*/
|
|
116
|
+
function sectionLines(content, heading) {
|
|
117
|
+
const pattern = new RegExp(`^## ${heading}\\s*$`, "m");
|
|
118
|
+
const match = pattern.exec(content);
|
|
119
|
+
if (!match || match.index === undefined) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rest = content.slice(match.index + match[0].length);
|
|
124
|
+
const nextHeading = rest.search(/^## /m);
|
|
125
|
+
const body = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
|
|
126
|
+
return body.split("\n").map((line) => line.trimEnd());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} content
|
|
131
|
+
* @returns {{
|
|
132
|
+
* status: string,
|
|
133
|
+
* candidates: Array<{ id: string, label: string, workspace: string, status: string }>,
|
|
134
|
+
* comparison: Record<string, Record<string, string>>,
|
|
135
|
+
* decision: { selected: string | null, mergedFrom: string | null, rationale: string | null },
|
|
136
|
+
* }}
|
|
137
|
+
*/
|
|
138
|
+
export function parseExplorationMarkdown(content) {
|
|
139
|
+
const statusLines = sectionLines(content, "Status");
|
|
140
|
+
const status = statusLines.find((line) => line && !line.startsWith("#"))?.trim() ?? "";
|
|
141
|
+
|
|
142
|
+
/** @type {Array<{ id: string, label: string, workspace: string, status: string }>} */
|
|
143
|
+
const candidates = [];
|
|
144
|
+
for (const line of sectionLines(content, "Candidates")) {
|
|
145
|
+
const cells = parseTableRow(line);
|
|
146
|
+
if (!cells || cells[0] === "Id") {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (cells.length >= 4) {
|
|
150
|
+
candidates.push({
|
|
151
|
+
id: cells[0],
|
|
152
|
+
label: cells[1],
|
|
153
|
+
workspace: cells[2],
|
|
154
|
+
status: cells[3],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @type {Record<string, Record<string, string>>} */
|
|
160
|
+
const comparison = {};
|
|
161
|
+
let comparisonHeaders = [];
|
|
162
|
+
|
|
163
|
+
for (const line of sectionLines(content, "Comparison")) {
|
|
164
|
+
const cells = parseTableRow(line);
|
|
165
|
+
if (!cells) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (cells[0] === "Criterion") {
|
|
169
|
+
comparisonHeaders = cells.slice(1);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const criterion = cells[0];
|
|
173
|
+
comparison[criterion] = {};
|
|
174
|
+
for (let i = 0; i < comparisonHeaders.length; i++) {
|
|
175
|
+
comparison[criterion][comparisonHeaders[i]] = cells[i + 1] ?? "";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const decisionLines = sectionLines(content, "Decision");
|
|
180
|
+
const decisionText = decisionLines.join("\n");
|
|
181
|
+
const selectedMatch = decisionText.match(/\*\*Selected\*\*:\s*(.+)/);
|
|
182
|
+
const mergedMatch = decisionText.match(/\*\*Merged from\*\*:\s*(.+)/);
|
|
183
|
+
const rationaleMatch = decisionText.match(/\*\*Rationale\*\*:\s*(.+)/);
|
|
184
|
+
|
|
185
|
+
const selectedRaw = selectedMatch?.[1]?.trim() ?? "";
|
|
186
|
+
const mergedRaw = mergedMatch?.[1]?.trim() ?? "";
|
|
187
|
+
const rationaleRaw = rationaleMatch?.[1]?.trim() ?? "";
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
status,
|
|
191
|
+
candidates,
|
|
192
|
+
comparison,
|
|
193
|
+
decision: {
|
|
194
|
+
selected:
|
|
195
|
+
!selectedRaw || /^\(pending\)|—|-$/i.test(selectedRaw) ? null : selectedRaw,
|
|
196
|
+
mergedFrom: !mergedRaw || /^—|-$/i.test(mergedRaw) ? null : mergedRaw,
|
|
197
|
+
rationale: !rationaleRaw || /^—|-$/i.test(rationaleRaw) ? null : rationaleRaw,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {ReturnType<typeof parseExplorationMarkdown>} parsed
|
|
204
|
+
* @param {{ requireDecision?: boolean, allowDecided?: boolean }} [options]
|
|
205
|
+
* @returns {{ ok: boolean, severity: "blocking" | "warning", messages: string[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function validateExploration(parsed, options = {}) {
|
|
208
|
+
const messages = [];
|
|
209
|
+
|
|
210
|
+
if (parsed.candidates.length < 2) {
|
|
211
|
+
messages.push("At least two candidates are required for solution exploration");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const candidateIds = parsed.candidates.map((item) => item.id);
|
|
215
|
+
|
|
216
|
+
for (const criterion of COMPARISON_CRITERIA) {
|
|
217
|
+
const row = parsed.comparison[criterion] ?? {};
|
|
218
|
+
for (const id of candidateIds) {
|
|
219
|
+
const value = (row[id] ?? "").trim();
|
|
220
|
+
if (!value) {
|
|
221
|
+
messages.push(`Comparison missing ${criterion} for candidate ${id}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (options.requireDecision) {
|
|
227
|
+
if (!parsed.decision.selected) {
|
|
228
|
+
messages.push("Decision must name a selected candidate");
|
|
229
|
+
}
|
|
230
|
+
if (!parsed.decision.rationale) {
|
|
231
|
+
messages.push("Decision must include a rationale");
|
|
232
|
+
}
|
|
233
|
+
} else if (!options.allowDecided && parsed.decision.selected) {
|
|
234
|
+
messages.push("Exploration is already decided — create a new feature to explore again");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
ok: messages.length === 0,
|
|
239
|
+
severity: "blocking",
|
|
240
|
+
messages,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {string} cwd
|
|
246
|
+
* @param {string} featureId
|
|
247
|
+
* @returns {Promise<string>}
|
|
248
|
+
*/
|
|
249
|
+
async function readExplorationFile(cwd, featureId) {
|
|
250
|
+
const explorationPath = path.join(featureDir(featureId, cwd), EXPLORATION_FILENAME);
|
|
251
|
+
return readFileSafe(explorationPath);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} cwd
|
|
256
|
+
* @param {string} featureId
|
|
257
|
+
* @param {Array<{ id: string, label?: string }>} candidateSpecs
|
|
258
|
+
* @param {{ baseRef?: string, force?: boolean }} [options]
|
|
259
|
+
*/
|
|
260
|
+
export async function initExploration(cwd, featureId, candidateSpecs, options = {}) {
|
|
261
|
+
const resolvedId = await resolveFeatureId(featureId, cwd);
|
|
262
|
+
const dir = featureDir(resolvedId, cwd);
|
|
263
|
+
const specPath = path.join(dir, "spec.md");
|
|
264
|
+
const explorationPath = path.join(dir, EXPLORATION_FILENAME);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await readFileSafe(specPath);
|
|
268
|
+
} catch {
|
|
269
|
+
throw new Error(`Approved spec required before exploration: ${specPath} not found`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!options.force) {
|
|
273
|
+
try {
|
|
274
|
+
await fs.access(explorationPath);
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${EXPLORATION_FILENAME} already exists for ${resolvedId} (use --force to replace)`,
|
|
277
|
+
);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (!(err instanceof Error) || !("code" in err) || err.code !== "ENOENT") {
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (candidateSpecs.length < 2) {
|
|
286
|
+
throw new Error("At least two candidates are required (e.g. --candidates A,B)");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @type {Array<{ id: string, label: string }>} */
|
|
290
|
+
const candidates = candidateSpecs.map((spec, index) => {
|
|
291
|
+
const id = normalizeCandidateId(spec.id);
|
|
292
|
+
const label = spec.label?.trim() || `Candidate ${id}`;
|
|
293
|
+
return { id, label };
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const ids = candidates.map((item) => item.id);
|
|
297
|
+
if (new Set(ids).size !== ids.length) {
|
|
298
|
+
throw new Error("Candidate ids must be unique");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const markdown = buildExplorationMarkdown(resolvedId, candidates, cwd);
|
|
302
|
+
await fs.mkdir(dir, { recursive: true });
|
|
303
|
+
await fs.writeFile(explorationPath, markdown, "utf8");
|
|
304
|
+
|
|
305
|
+
const workspaceIds = candidates.map(({ id }) => candidateWorkspaceId(id));
|
|
306
|
+
const workspaces = await prepareWorkspaces(cwd, {
|
|
307
|
+
featureId: resolvedId,
|
|
308
|
+
taskIds: workspaceIds,
|
|
309
|
+
baseRef: options.baseRef ?? "HEAD",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
featureId: resolvedId,
|
|
314
|
+
explorationPath,
|
|
315
|
+
candidates,
|
|
316
|
+
workspaces,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {string} cwd
|
|
322
|
+
* @param {string} [featureId]
|
|
323
|
+
*/
|
|
324
|
+
export async function getExplorationStatus(cwd, featureId) {
|
|
325
|
+
const resolvedId = await resolveFeatureId(featureId, cwd);
|
|
326
|
+
const content = await readExplorationFile(cwd, resolvedId);
|
|
327
|
+
const parsed = parseExplorationMarkdown(content);
|
|
328
|
+
const workspaces = await listWorkspaces(cwd, resolvedId);
|
|
329
|
+
const candidateWorkspaces = workspaces.filter((item) => item.taskId.startsWith("candidate-"));
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
featureId: resolvedId,
|
|
333
|
+
...parsed,
|
|
334
|
+
workspaces: candidateWorkspaces,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {string} cwd
|
|
340
|
+
* @param {string} featureId
|
|
341
|
+
*/
|
|
342
|
+
export async function validateExplorationArtifact(cwd, featureId) {
|
|
343
|
+
const resolvedId = await resolveFeatureId(featureId, cwd);
|
|
344
|
+
const content = await readExplorationFile(cwd, resolvedId);
|
|
345
|
+
const parsed = parseExplorationMarkdown(content);
|
|
346
|
+
const result = validateExploration(parsed, { requireDecision: false });
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
featureId: resolvedId,
|
|
350
|
+
...parsed,
|
|
351
|
+
...result,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @param {string} content
|
|
357
|
+
* @param {{ selected: string, mergedFrom?: string | null, rationale: string }} decision
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
export function applyDecisionToMarkdown(content, decision) {
|
|
361
|
+
let updated = content.replace(
|
|
362
|
+
/^## Status\s*\n\s*\S+/m,
|
|
363
|
+
"## Status\n\ndecided",
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
updated = updated.replace(
|
|
367
|
+
/\*\*Selected\*\*:\s*.+/,
|
|
368
|
+
`**Selected**: ${decision.selected}`,
|
|
369
|
+
);
|
|
370
|
+
updated = updated.replace(
|
|
371
|
+
/\*\*Merged from\*\*:\s*.+/,
|
|
372
|
+
`**Merged from**: ${decision.mergedFrom ?? "—"}`,
|
|
373
|
+
);
|
|
374
|
+
updated = updated.replace(
|
|
375
|
+
/\*\*Rationale\*\*:\s*.+/,
|
|
376
|
+
`**Rationale**: ${decision.rationale}`,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
return updated;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} cwd
|
|
384
|
+
* @param {string} featureId
|
|
385
|
+
* @param {{ selected: string, mergedFrom?: string | null, rationale: string, cleanup?: boolean }} decision
|
|
386
|
+
*/
|
|
387
|
+
export async function recordExplorationDecision(cwd, featureId, decision) {
|
|
388
|
+
const resolvedId = await resolveFeatureId(featureId, cwd);
|
|
389
|
+
const explorationPath = path.join(featureDir(resolvedId, cwd), EXPLORATION_FILENAME);
|
|
390
|
+
const content = await readExplorationFile(cwd, resolvedId);
|
|
391
|
+
const parsed = parseExplorationMarkdown(content);
|
|
392
|
+
|
|
393
|
+
const selected = normalizeCandidateId(decision.selected);
|
|
394
|
+
const candidateIds = new Set(parsed.candidates.map((item) => item.id));
|
|
395
|
+
if (!candidateIds.has(selected)) {
|
|
396
|
+
throw new Error(`Unknown candidate "${selected}" — expected one of: ${[...candidateIds].join(", ")}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let mergedFrom = decision.mergedFrom?.trim() || null;
|
|
400
|
+
if (mergedFrom) {
|
|
401
|
+
mergedFrom = normalizeCandidateId(mergedFrom);
|
|
402
|
+
if (!candidateIds.has(mergedFrom)) {
|
|
403
|
+
throw new Error(`Unknown merge candidate "${mergedFrom}"`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const rationale = decision.rationale?.trim();
|
|
408
|
+
if (!rationale) {
|
|
409
|
+
throw new Error("Decision requires --rationale");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const comparisonCheck = validateExploration(parsed, { allowDecided: true });
|
|
413
|
+
if (!comparisonCheck.ok) {
|
|
414
|
+
throw new Error(comparisonCheck.messages.join("; "));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (parsed.decision.selected) {
|
|
418
|
+
throw new Error("Exploration is already decided");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const updated = applyDecisionToMarkdown(content, {
|
|
422
|
+
selected,
|
|
423
|
+
mergedFrom,
|
|
424
|
+
rationale,
|
|
425
|
+
});
|
|
426
|
+
await fs.writeFile(explorationPath, updated, "utf8");
|
|
427
|
+
|
|
428
|
+
/** @type {Awaited<ReturnType<typeof cleanupWorkspaces>> | null} */
|
|
429
|
+
let cleanup = null;
|
|
430
|
+
if (decision.cleanup) {
|
|
431
|
+
const keep = new Set([candidateWorkspaceId(selected)]);
|
|
432
|
+
if (mergedFrom) {
|
|
433
|
+
keep.add(candidateWorkspaceId(mergedFrom));
|
|
434
|
+
}
|
|
435
|
+
const removeIds = parsed.candidates
|
|
436
|
+
.map((item) => candidateWorkspaceId(item.id))
|
|
437
|
+
.filter((workspaceId) => !keep.has(workspaceId));
|
|
438
|
+
if (removeIds.length) {
|
|
439
|
+
cleanup = await cleanupWorkspaces(cwd, {
|
|
440
|
+
featureId: resolvedId,
|
|
441
|
+
taskIds: removeIds,
|
|
442
|
+
force: true,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
featureId: resolvedId,
|
|
449
|
+
selected,
|
|
450
|
+
mergedFrom,
|
|
451
|
+
rationale,
|
|
452
|
+
cleanup,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* @param {Awaited<ReturnType<typeof getExplorationStatus>>} status
|
|
458
|
+
* @param {{ json?: boolean }} [options]
|
|
459
|
+
*/
|
|
460
|
+
export function formatExplorationStatus(status, options = {}) {
|
|
461
|
+
if (options.json) {
|
|
462
|
+
return `${JSON.stringify(status, null, 2)}\n`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const lines = [
|
|
466
|
+
`Solution exploration: ${status.featureId}`,
|
|
467
|
+
` Status: ${status.status || "(unset)"}`,
|
|
468
|
+
" Candidates:",
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
if (!status.candidates.length) {
|
|
472
|
+
lines.push(" (none)");
|
|
473
|
+
} else {
|
|
474
|
+
for (const candidate of status.candidates) {
|
|
475
|
+
lines.push(` ${candidate.id}: ${candidate.label} (${candidate.status})`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (status.decision.selected) {
|
|
480
|
+
lines.push(` Decision: ${status.decision.selected}`);
|
|
481
|
+
if (status.decision.mergedFrom) {
|
|
482
|
+
lines.push(` Merged from: ${status.decision.mergedFrom}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return `${lines.join("\n")}\n`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @param {{ ok: boolean, messages: string[], featureId: string }} result
|
|
491
|
+
* @param {{ json?: boolean }} [options]
|
|
492
|
+
*/
|
|
493
|
+
export function formatExplorationValidation(result, options = {}) {
|
|
494
|
+
if (options.json) {
|
|
495
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const label = result.ok ? "PASS" : "FAIL";
|
|
499
|
+
const lines = [`[solution-explore] ${label} - ${result.featureId}`];
|
|
500
|
+
for (const message of result.messages) {
|
|
501
|
+
lines.push(` ${message}`);
|
|
502
|
+
}
|
|
503
|
+
return `${lines.join("\n")}\n`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {Awaited<ReturnType<typeof initExploration>>} result
|
|
508
|
+
* @param {{ json?: boolean }} [options]
|
|
509
|
+
*/
|
|
510
|
+
export function formatExplorationInit(result, options = {}) {
|
|
511
|
+
if (options.json) {
|
|
512
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const lines = [
|
|
516
|
+
`Initialized exploration for ${result.featureId}`,
|
|
517
|
+
` Artifact: ${result.explorationPath}`,
|
|
518
|
+
" Candidates:",
|
|
519
|
+
];
|
|
520
|
+
for (const candidate of result.candidates) {
|
|
521
|
+
const workspace = workspacePath(process.cwd(), result.featureId, candidateWorkspaceId(candidate.id));
|
|
522
|
+
lines.push(` ${candidate.id}: ${candidate.label} → ${workspace}`);
|
|
523
|
+
}
|
|
524
|
+
for (const workspace of result.workspaces) {
|
|
525
|
+
lines.push(` Workspace ${workspace.taskId}: ${workspace.status}`);
|
|
526
|
+
}
|
|
527
|
+
return `${lines.join("\n")}\n`;
|
|
528
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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 test/test_adapter_registry.test.js test/test_context_guard.test.js",
|
|
15
|
+
"test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js test/test_classify_change.test.js test/test_feature_status.test.js test/test_agent_contract.test.js test/test_gates_python.test.js test/test_specs_utils.test.js test/test_validation_verdict.test.js test/test_execution_policy.test.js test/test_workspace_isolation.test.js test/test_adapter_registry.test.js test/test_context_guard.test.js test/test_solution_exploration.test.js",
|
|
16
16
|
"test:gates": "node test/run-gate-tests.mjs",
|
|
17
17
|
"prepublishOnly": "npm test"
|
|
18
18
|
},
|
|
@@ -50,6 +50,8 @@ Structural gates run **before** owner review, so they cannot drift when the mode
|
|
|
50
50
|
| After parallel wave merge | `npx @luizsantiago/spec-guardrails workspace-cleanup [feature] --force` |
|
|
51
51
|
| Before editing paths outside task Files | `npx @luizsantiago/spec-guardrails context-guard check-edit <path> [--op write]` |
|
|
52
52
|
| Before claiming feature complete | `npx @luizsantiago/spec-guardrails context-guard check-complete [feature]` |
|
|
53
|
+
| Solution exploration (explicit) | `npx @luizsantiago/spec-guardrails solution-explore init <feature> --candidates A,B` |
|
|
54
|
+
| Before exploration decision | `npx @luizsantiago/spec-guardrails solution-explore validate [feature]` |
|
|
53
55
|
| Retrieve related context | `npx @luizsantiago/spec-guardrails memory-search "<terms>"` |
|
|
54
56
|
| On gate retry (Execute playbook) | `npx @luizsantiago/spec-guardrails execution-policy record-retry Tn` |
|
|
55
57
|
| On each commit | `python3 .specs/guardrails/scripts/check_commit.py --message "<message>"` |
|
|
@@ -89,6 +91,7 @@ EXPLORE (optional) → SPECIFY → DISCUSS (conditional) → DESIGN (optional)
|
|
|
89
91
|
| **Quick** | Alternative | `references/quick-mode.md` | — | `check_commit.py`, `validate_quick.py` |
|
|
90
92
|
| **Context** | Always | `references/context-limits.md` | — | — |
|
|
91
93
|
| **Sub-agents** | When batched | `references/sub-agents.md` | `task-graph-engineering.md` | — |
|
|
94
|
+
| **Solution exploration** | Explicit fork | `references/solution-exploration.md` | — | `solution-explore validate` |
|
|
92
95
|
| **Lessons** | On FAIL | `references/lessons.md` | — | `lessons.py` |
|
|
93
96
|
|
|
94
97
|
Context is a load rule, not a pipeline phase. Read it when the session is long or the feature has more than a handful of tasks. Sub-agents is the Execute scaling protocol — offer only when the task graph needs more than one batch; see `references/sub-agents.md`. Lessons is a FAIL-path step, not a sequential phase — see `references/lessons.md`.
|
|
@@ -144,6 +147,7 @@ When in doubt, start at **Medium** and drop phases only with owner approval.
|
|
|
144
147
|
| `.specs/features/[feature]/spec.md` | Requirements (use `NNN-slug` from `feature-init`) |
|
|
145
148
|
| `.specs/features/[feature]/context.md` | Owner decisions for gray areas (only when Discuss ran) |
|
|
146
149
|
| `.specs/features/[feature]/design.md` | Architecture (Complex tier) |
|
|
150
|
+
| `.specs/features/[feature]/exploration.md` | Solution exploration candidates + comparison (explicit mode) |
|
|
147
151
|
| `.specs/features/[feature]/tasks.md` | Atomic task breakdown |
|
|
148
152
|
| `.specs/features/[feature]/task-graph.md` | Job DAG and parallel groups (when applicable) |
|
|
149
153
|
| `.specs/features/[feature]/validation.md` | Independent verification report |
|
|
@@ -81,6 +81,7 @@ Skipping Design is the default for Simple and Medium tiers.
|
|
|
81
81
|
|
|
82
82
|
- No implementation code in `design.md` — interfaces and signatures only when they clarify a contract.
|
|
83
83
|
- If the design reveals that the spec is wrong or incomplete, stop and update `spec.md` first; re-run the spec gate.
|
|
84
|
+
- When multiple defensible implementations exist for the same spec, use explicit **solution exploration** (`references/solution-exploration.md`) before Execute — not parallel ad-hoc spikes.
|
|
84
85
|
- Prefer the smallest design that satisfies the spec. Extensibility that no requirement asks for is speculation.
|
|
85
86
|
|
|
86
87
|
## Next
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Solution Exploration
|
|
2
|
+
|
|
3
|
+
Compare multiple candidate implementations from the same **approved spec** before committing to one approach. Explicit mode — not the default Execute path.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
- Two or more defensible architectures or libraries for the same requirements
|
|
8
|
+
- High-stakes design fork (auth strategy, persistence, messaging, language/runtime choice)
|
|
9
|
+
- Owner asked to spike and compare before `/loop`
|
|
10
|
+
|
|
11
|
+
## When NOT to Use
|
|
12
|
+
|
|
13
|
+
- Ordinary Execute with an approved design and tasks — use `implement.md`
|
|
14
|
+
- Pre-spec ideation — use `explore.md` instead
|
|
15
|
+
- Quick tier (≤3 files) — use `quick-mode.md`
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- Approved `spec.md` (`validate-spec` PASS)
|
|
20
|
+
- Git repository (worktrees for isolation)
|
|
21
|
+
- Owner approval to explore **multiple** implementations (higher blast radius than single-path Execute)
|
|
22
|
+
|
|
23
|
+
## Outputs
|
|
24
|
+
|
|
25
|
+
- `.specs/features/[feature]/exploration.md` — candidates, comparison matrix, decision
|
|
26
|
+
- Isolated workspaces under `.specs/workspaces/[feature]/candidate-<id>/`
|
|
27
|
+
|
|
28
|
+
## Procedure
|
|
29
|
+
|
|
30
|
+
1. **Confirm the spec is approved** — run `validate-spec` if uncertain.
|
|
31
|
+
2. **Initialize exploration** (owner OK required):
|
|
32
|
+
```bash
|
|
33
|
+
npx @luizsantiago/spec-guardrails solution-explore init <feature> --candidates A,B,C
|
|
34
|
+
```
|
|
35
|
+
Optional labels: `--labels "Redis,Postgres,Hybrid"`.
|
|
36
|
+
3. **Implement each candidate in its workspace** — one candidate per worktree; no cross-contamination. Production-quality spikes only if the owner expects mergeable code; otherwise time-boxed prototypes with evidence.
|
|
37
|
+
4. **Fill the comparison matrix** in `exploration.md` for every criterion:
|
|
38
|
+
- Spec compliance
|
|
39
|
+
- Test results
|
|
40
|
+
- Complexity
|
|
41
|
+
- Maintainability
|
|
42
|
+
- Performance
|
|
43
|
+
- Risk
|
|
44
|
+
5. **Validate before deciding**:
|
|
45
|
+
```bash
|
|
46
|
+
npx @luizsantiago/spec-guardrails solution-explore validate <feature>
|
|
47
|
+
```
|
|
48
|
+
6. **Record the decision** (owner picks):
|
|
49
|
+
```bash
|
|
50
|
+
npx @luizsantiago/spec-guardrails solution-explore select <feature> \
|
|
51
|
+
--candidate A --rationale "Best spec fit and lowest operational risk"
|
|
52
|
+
```
|
|
53
|
+
Optional merge: `--merge B`. Optional cleanup of losing worktrees: `--cleanup`.
|
|
54
|
+
7. **Promote the winner** — merge chosen work into the main tree, update `design.md` / `tasks.md` if needed, then continue normal Execute on the main branch.
|
|
55
|
+
|
|
56
|
+
## Rules
|
|
57
|
+
|
|
58
|
+
- **Explicit opt-in** — never spawn parallel solution paths without owner approval.
|
|
59
|
+
- **Same spec** — all candidates implement the same approved requirements; gaps go back to Specify.
|
|
60
|
+
- **Evidence over opinion** — comparison cells cite tests, benchmarks, or concrete observations.
|
|
61
|
+
- **One decision** — after `select`, exploration is closed; do not restart without a new feature or owner direction.
|
|
62
|
+
- **Independent verify still applies** — `/verify` runs on the merged result, not on exploration chat summaries.
|
|
63
|
+
|
|
64
|
+
## Gates and CLI
|
|
65
|
+
|
|
66
|
+
| Step | Command |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| Init | `solution-explore init <feature> --candidates A,B` |
|
|
69
|
+
| Status | `solution-explore status [feature]` |
|
|
70
|
+
| Before select | `solution-explore validate [feature]` |
|
|
71
|
+
| Decide | `solution-explore select <feature> --candidate A --rationale "…"` |
|
|
72
|
+
|
|
73
|
+
## Anti-Patterns
|
|
74
|
+
|
|
75
|
+
| Avoid | Prefer |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| Exploring without an approved spec | `validate-spec` then `solution-explore init` |
|
|
78
|
+
| Mixing candidates in one worktree | One worktree per candidate id |
|
|
79
|
+
| Skipping the comparison matrix | Fill all criteria before `select` |
|
|
80
|
+
| Default parallel Execute for every feature | Solution exploration only when trade-offs are real |
|
|
81
|
+
|
|
82
|
+
## Next
|
|
83
|
+
|
|
84
|
+
- Decision recorded → update `design.md`, resume `tasks.md` / `implement.md`
|
|
85
|
+
- Spec gaps found → `converge.md` or update `spec.md`
|
|
86
|
+
- Back → `agent-architecture.md`
|