@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 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.4.x**
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.4.0",
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`