@luizsantiago/spec-guardrails 3.3.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.3.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,8 @@ 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 |
221
+ | **3.4.x** | Contextual guards (`context-guard`); FTS memory search (`memory-search`) |
220
222
  | **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
221
223
  | **3.2.x** | Single-package focus; artifact gate severity labels; git worktree isolation CLI; execution policy (budget/scope/escalation) |
222
224
  | **3.1.x** | Copilot/Codex/AGENTS.md adapters; doctor Process + Brakes scores; `validate-traceability` / `validate-quick`; `classify-change` / `feature-status` |
package/index.js CHANGED
@@ -5,6 +5,13 @@ import path from "node:path";
5
5
  import { archiveFeature } from "./lib/archive.js";
6
6
  import { projectInit } from "./lib/brownfield.js";
7
7
  import { classifyChange, formatClassifyChange } from "./lib/classify-change.js";
8
+ import {
9
+ checkBeforeComplete,
10
+ checkBeforeEdit,
11
+ evaluateExecuteContext,
12
+ formatCheckBeforeEdit,
13
+ formatContextGuardStatus,
14
+ } from "./lib/context-guard.js";
8
15
  import { PACKAGE_VERSION, CLI_NAME } from "./lib/constants.js";
9
16
  import { phaseContext } from "./lib/config.js";
10
17
  import { doctor } from "./lib/doctor.js";
@@ -28,6 +35,16 @@ import {
28
35
  listWorkspaces,
29
36
  prepareWorkspaces,
30
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";
31
48
  import {
32
49
  initProjectConfig,
33
50
  listPresets,
@@ -89,6 +106,34 @@ Commands:
89
106
  memory-query --from <id> Bounded context package from the knowledge graph
90
107
  [--depth N] Traversal depth (default 2)
91
108
  [--json] Machine-readable output
109
+ memory-search <query> Full-text search over the memory index (FTS5)
110
+ [--limit N] Max results (default 10)
111
+ [--json] Machine-readable output
112
+ context-guard status Execute readiness from STATE + tasks.md
113
+ [--json] Machine-readable output
114
+ context-guard check-edit <path> Contextual guard before editing a file
115
+ [--op read|write|delete] Intended operation (default: inferred)
116
+ [--no-strict-files] Skip task Files allowlist check
117
+ [--json] Machine-readable output
118
+ context-guard check-complete Contextual guard before claiming feature done
119
+ [feature] Feature id (default: active in STATE)
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
92
137
  validate-spec [spec.md|feature] Closure gate for a feature spec
93
138
  analyze-artifacts [feature] Cross-artifact consistency before task approval
94
139
  validate-tasks [tasks.md|feature] Granularity gate for a task breakdown
@@ -565,6 +610,218 @@ if (command === "--version" || command === "-v" || command === "version") {
565
610
  console.error(`❌ ${err.message}`);
566
611
  process.exit(1);
567
612
  }
613
+ } else if (command === "context-guard") {
614
+ try {
615
+ const sub = args[0];
616
+ let json = false;
617
+ const rest = [];
618
+
619
+ for (let i = 1; i < args.length; i++) {
620
+ if (args[i] === "--json") {
621
+ json = true;
622
+ } else {
623
+ rest.push(args[i]);
624
+ }
625
+ }
626
+
627
+ const cwd = process.cwd();
628
+
629
+ if (sub === "status") {
630
+ const context = await evaluateExecuteContext(cwd);
631
+ process.stdout.write(formatContextGuardStatus(context, { json }));
632
+ if (!context.ok && context.severity === "blocking") {
633
+ process.exit(1);
634
+ }
635
+ } else if (sub === "check-edit") {
636
+ let operation;
637
+ let strictFiles = true;
638
+ const positional = [];
639
+
640
+ for (let i = 0; i < rest.length; i++) {
641
+ const arg = rest[i];
642
+ if (arg === "--no-strict-files") {
643
+ strictFiles = false;
644
+ } else if (arg === "--op") {
645
+ operation = rest[++i];
646
+ if (!operation) {
647
+ throw new Error("--op requires read, write, or delete");
648
+ }
649
+ } else {
650
+ positional.push(arg);
651
+ }
652
+ }
653
+
654
+ const relativePath = positional[0];
655
+ if (!relativePath) {
656
+ throw new Error(
657
+ "Usage: context-guard check-edit <relative-path> [--op read|write|delete] [--no-strict-files]",
658
+ );
659
+ }
660
+
661
+ const result = await checkBeforeEdit(cwd, relativePath, { operation, strictFiles });
662
+ process.stdout.write(formatCheckBeforeEdit(result, relativePath, { json }));
663
+ if (result.exitCode !== 0) {
664
+ process.exit(result.exitCode);
665
+ }
666
+ } else if (sub === "check-complete") {
667
+ const featureId = rest[0];
668
+ const result = await checkBeforeComplete(cwd, featureId);
669
+ if (json) {
670
+ console.log(JSON.stringify(result, null, 2));
671
+ } else {
672
+ const label = result.allowed ? "ready" : "blocked";
673
+ console.log(`Feature ${result.featureId}: ${label}`);
674
+ for (const message of result.messages) {
675
+ console.log(` ${message}`);
676
+ }
677
+ }
678
+ if (result.exitCode !== 0) {
679
+ process.exit(result.exitCode);
680
+ }
681
+ } else {
682
+ throw new Error(
683
+ "Usage: context-guard status | check-edit <path> | check-complete [feature]",
684
+ );
685
+ }
686
+ } catch (err) {
687
+ console.error(`❌ ${err.message}`);
688
+ process.exit(1);
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
+ }
568
825
  } else if (command === "classify-change") {
569
826
  try {
570
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" },
@@ -110,6 +111,7 @@ export const SCRIPT_ASSETS = [
110
111
  { file: "loop_plan.py", remotePath: "scripts/loop_plan.py" },
111
112
  { file: "memory_index.py", remotePath: "scripts/memory_index.py" },
112
113
  { file: "memory_query.py", remotePath: "scripts/memory_query.py" },
114
+ { file: "memory_search.py", remotePath: "scripts/memory_search.py" },
113
115
  ];
114
116
 
115
117
  /** @type {{ file: string, remotePath: string }[]} */
@@ -0,0 +1,340 @@
1
+ import path from "node:path";
2
+
3
+ import { loadExecutionPolicy, resolvePathCheck } from "./execution-policy.js";
4
+ import { readFileSafe } from "./fs-utils.js";
5
+ import { featureDir, readActiveFeatureFromState, resolveFeatureId } from "./specs-utils.js";
6
+ import { findVerdict } from "./validation-verdict.js";
7
+
8
+ const TASK_FILES = /^\s*[-*]?\s*\*{0,2}Files\*{0,2}\s*:\s*(.+)$/gim;
9
+
10
+ /**
11
+ * @param {string} cwd
12
+ * @returns {Promise<{ featureId: string | null, phase: string | null }>}
13
+ */
14
+ export async function readStateContext(cwd) {
15
+ const statePath = path.join(cwd, ".specs/STATE.md");
16
+ let content = "";
17
+
18
+ try {
19
+ content = await readFileSafe(statePath);
20
+ } catch {
21
+ return { featureId: null, phase: null };
22
+ }
23
+
24
+ const featureId = await readActiveFeatureFromState(cwd);
25
+ const phaseMatch = content.match(/^-\s*Phase:\s*(.+)$/m);
26
+ const phase = phaseMatch ? phaseMatch[1].trim() : null;
27
+
28
+ return { featureId, phase: phase && !/^—|-$/i.test(phase) ? phase : null };
29
+ }
30
+
31
+ /**
32
+ * @param {string} tasksText
33
+ * @returns {number}
34
+ */
35
+ export function countOpenTasks(tasksText) {
36
+ return [...tasksText.matchAll(/^\s*[-*]\s*\[ \]\s+/gm)].length;
37
+ }
38
+
39
+ /**
40
+ * @param {string} tasksText
41
+ * @returns {Set<string>}
42
+ */
43
+ export function collectTaskFilePaths(tasksText) {
44
+ /** @type {Set<string>} */
45
+ const files = new Set();
46
+
47
+ for (const match of tasksText.matchAll(TASK_FILES)) {
48
+ const raw = match[1].trim();
49
+ if (!raw || /^—|-$|none|n\/a$/i.test(raw)) {
50
+ continue;
51
+ }
52
+ for (const part of raw.split(/[,;]/)) {
53
+ const cleaned = part.trim().replace(/^`|`$/g, "");
54
+ if (cleaned) {
55
+ files.add(cleaned.replace(/\\/g, "/"));
56
+ }
57
+ }
58
+ }
59
+
60
+ return files;
61
+ }
62
+
63
+ /**
64
+ * @param {string} relativePath
65
+ * @param {Set<string>} approvedFiles
66
+ * @returns {boolean}
67
+ */
68
+ export function pathInApprovedTaskFiles(relativePath, approvedFiles) {
69
+ if (!approvedFiles.size) {
70
+ return true;
71
+ }
72
+
73
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
74
+ for (const approved of approvedFiles) {
75
+ const pattern = approved.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
76
+ if (new RegExp(`^${pattern}$`).test(normalized)) {
77
+ return true;
78
+ }
79
+ if (normalized === approved || normalized.endsWith(`/${approved}`)) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * @param {string} cwd
88
+ * @param {{ featureId?: string }} [options]
89
+ * @returns {Promise<{
90
+ * ok: boolean,
91
+ * severity: "blocking" | "warning" | "info",
92
+ * featureId: string | null,
93
+ * phase: string | null,
94
+ * openTasks: number,
95
+ * messages: string[],
96
+ * }>}
97
+ */
98
+ export async function evaluateExecuteContext(cwd, options = {}) {
99
+ const messages = [];
100
+ const state = await readStateContext(cwd);
101
+ let featureId = options.featureId ?? state.featureId;
102
+
103
+ if (!featureId) {
104
+ return {
105
+ ok: false,
106
+ severity: "blocking",
107
+ featureId: null,
108
+ phase: state.phase,
109
+ openTasks: 0,
110
+ messages: ["no active feature in .specs/STATE.md — run feature-init or /specify first"],
111
+ };
112
+ }
113
+
114
+ const tasksPath = path.join(featureDir(featureId, cwd), "tasks.md");
115
+ let tasksText = "";
116
+
117
+ try {
118
+ tasksText = await readFileSafe(tasksPath);
119
+ } catch {
120
+ return {
121
+ ok: false,
122
+ severity: "blocking",
123
+ featureId,
124
+ phase: state.phase,
125
+ openTasks: 0,
126
+ messages: [`tasks.md missing for active feature ${featureId}`],
127
+ };
128
+ }
129
+
130
+ const openTasks = countOpenTasks(tasksText);
131
+ if (openTasks === 0) {
132
+ messages.push("no open tasks in tasks.md — Execute may be complete; run /verify instead");
133
+ } else {
134
+ messages.push(`${openTasks} open task(s) in tasks.md`);
135
+ }
136
+
137
+ const severity = openTasks === 0 ? "warning" : "info";
138
+ return {
139
+ ok: openTasks > 0,
140
+ severity,
141
+ featureId,
142
+ phase: state.phase,
143
+ openTasks,
144
+ messages,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {string} cwd
150
+ * @param {string} relativePath
151
+ * @param {{ operation?: string, featureId?: string, strictFiles?: boolean }} [options]
152
+ */
153
+ export async function checkBeforeEdit(cwd, relativePath, options = {}) {
154
+ const execute = await evaluateExecuteContext(cwd, { featureId: options.featureId });
155
+ const policy = await loadExecutionPolicy(cwd);
156
+ const pathCheck = resolvePathCheck(relativePath, policy, {
157
+ operation: options.operation,
158
+ });
159
+
160
+ /** @type {string[]} */
161
+ const messages = [...execute.messages];
162
+
163
+ if (!execute.ok && execute.severity === "blocking") {
164
+ return {
165
+ allowed: false,
166
+ exitCode: 1,
167
+ severity: "blocking",
168
+ operation: pathCheck.operation,
169
+ featureId: execute.featureId,
170
+ messages,
171
+ };
172
+ }
173
+
174
+ if (!pathCheck.allowed) {
175
+ messages.push(pathCheck.reason);
176
+ return {
177
+ allowed: false,
178
+ exitCode: pathCheck.exitCode,
179
+ severity: pathCheck.severity,
180
+ operation: pathCheck.operation,
181
+ featureId: execute.featureId,
182
+ messages,
183
+ };
184
+ }
185
+
186
+ if (options.strictFiles !== false && execute.featureId) {
187
+ const tasksText = await readFileSafe(
188
+ path.join(featureDir(execute.featureId, cwd), "tasks.md"),
189
+ );
190
+ const approved = collectTaskFilePaths(tasksText);
191
+ if (approved.size && !pathInApprovedTaskFiles(relativePath, approved)) {
192
+ messages.push("path is not listed in any task Files field — escalate or update tasks.md");
193
+ const mode = policy.escalation.on_scope_expansion ?? "human";
194
+ if (mode === "human") {
195
+ return {
196
+ allowed: false,
197
+ exitCode: 1,
198
+ severity: "blocking",
199
+ operation: pathCheck.operation,
200
+ featureId: execute.featureId,
201
+ messages,
202
+ };
203
+ }
204
+ }
205
+ }
206
+
207
+ if (pathCheck.severity === "warning") {
208
+ messages.push(pathCheck.reason);
209
+ }
210
+
211
+ if (!execute.ok && execute.severity === "warning") {
212
+ messages.push(...execute.messages);
213
+ }
214
+
215
+ return {
216
+ allowed: true,
217
+ exitCode: 0,
218
+ severity: pathCheck.severity === "warning" || execute.severity === "warning" ? "warning" : "info",
219
+ operation: pathCheck.operation,
220
+ featureId: execute.featureId,
221
+ messages,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * @param {string} cwd
227
+ * @param {string} [featureRaw]
228
+ */
229
+ export async function checkBeforeComplete(cwd, featureRaw) {
230
+ const featureId = featureRaw ? await resolveFeatureId(featureRaw, cwd) : await resolveFeatureId(undefined, cwd);
231
+ const dir = featureDir(featureId, cwd);
232
+ /** @type {string[]} */
233
+ const messages = [];
234
+
235
+ let tasksText = "";
236
+ try {
237
+ tasksText = await readFileSafe(path.join(dir, "tasks.md"));
238
+ } catch {
239
+ return {
240
+ allowed: false,
241
+ exitCode: 1,
242
+ severity: "blocking",
243
+ featureId,
244
+ messages: ["tasks.md missing — cannot claim completion"],
245
+ };
246
+ }
247
+
248
+ const openTasks = countOpenTasks(tasksText);
249
+ if (openTasks > 0) {
250
+ messages.push(`${openTasks} task(s) still open in tasks.md`);
251
+ return {
252
+ allowed: false,
253
+ exitCode: 1,
254
+ severity: "blocking",
255
+ featureId,
256
+ messages,
257
+ };
258
+ }
259
+
260
+ const validationPath = path.join(dir, "validation.md");
261
+ let validationText = "";
262
+ try {
263
+ validationText = await readFileSafe(validationPath);
264
+ } catch {
265
+ messages.push("validation.md missing — run independent /verify first");
266
+ return {
267
+ allowed: false,
268
+ exitCode: 1,
269
+ severity: "blocking",
270
+ featureId,
271
+ messages,
272
+ };
273
+ }
274
+
275
+ const verdict = findVerdict(validationText);
276
+ if (!verdict || !["PASS", "PASSED"].includes(verdict)) {
277
+ messages.push(verdict ? `validation verdict is ${verdict}, not PASS` : "validation.md has no PASS/FAIL verdict");
278
+ return {
279
+ allowed: false,
280
+ exitCode: 1,
281
+ severity: "blocking",
282
+ featureId,
283
+ messages,
284
+ };
285
+ }
286
+
287
+ messages.push("tasks complete and validation PASS recorded");
288
+ return {
289
+ allowed: true,
290
+ exitCode: 0,
291
+ severity: "info",
292
+ featureId,
293
+ messages,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * @param {Awaited<ReturnType<typeof evaluateExecuteContext>>} context
299
+ * @param {{ json?: boolean }} [options]
300
+ */
301
+ export function formatContextGuardStatus(context, options = {}) {
302
+ if (options.json) {
303
+ return JSON.stringify(context, null, 2);
304
+ }
305
+
306
+ const lines = ["Context guard status:"];
307
+ lines.push(` feature: ${context.featureId ?? "(none)"}`);
308
+ lines.push(` phase: ${context.phase ?? "(unknown)"}`);
309
+ lines.push(` open_tasks: ${context.openTasks}`);
310
+ lines.push(` execute_ready: ${context.ok ? "yes" : "no"}`);
311
+ for (const message of context.messages) {
312
+ lines.push(` note: ${message}`);
313
+ }
314
+ return `${lines.join("\n")}\n`;
315
+ }
316
+
317
+ /**
318
+ * @param {Awaited<ReturnType<typeof checkBeforeEdit>>} result
319
+ * @param {string} relativePath
320
+ * @param {{ json?: boolean }} [options]
321
+ */
322
+ export function formatCheckBeforeEdit(result, relativePath, options = {}) {
323
+ if (options.json) {
324
+ return JSON.stringify({ path: relativePath, ...result }, null, 2);
325
+ }
326
+
327
+ const label = result.allowed
328
+ ? result.severity === "warning"
329
+ ? "allowed (warn)"
330
+ : "allowed"
331
+ : result.severity === "warning"
332
+ ? "blocked (warn)"
333
+ : "blocked";
334
+
335
+ const lines = [`${relativePath} [${result.operation}]: ${label}`];
336
+ for (const message of result.messages) {
337
+ lines.push(` ${message}`);
338
+ }
339
+ return `${lines.join("\n")}\n`;
340
+ }
package/lib/gates.js CHANGED
@@ -43,6 +43,7 @@ const AUX_SCRIPTS = {
43
43
  "loop-plan": "loop_plan.py",
44
44
  "memory-index": "memory_index.py",
45
45
  "memory-query": "memory_query.py",
46
+ "memory-search": "memory_search.py",
46
47
  };
47
48
 
48
49
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };