@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 +3 -1
- package/index.js +257 -0
- package/lib/constants.js +2 -0
- package/lib/context-guard.js +340 -0
- package/lib/gates.js +1 -0
- package/lib/solution-exploration.js +528 -0
- package/package.json +2 -2
- package/scripts/memory_search.py +119 -0
- package/skills/agent-architecture.md +7 -1
- package/skills/references/design.md +1 -0
- package/skills/references/implement.md +3 -2
- 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,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