@luizsantiago/spec-guardrails 3.0.1
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/LICENSE +21 -0
- package/README.md +206 -0
- package/index.js +335 -0
- package/lib/archive.js +208 -0
- package/lib/assets.js +145 -0
- package/lib/brownfield.js +446 -0
- package/lib/config.js +293 -0
- package/lib/constants.js +262 -0
- package/lib/cursorrules.js +92 -0
- package/lib/delta-merge.js +248 -0
- package/lib/doctor.js +343 -0
- package/lib/download.js +133 -0
- package/lib/feature.js +272 -0
- package/lib/fs-utils.js +114 -0
- package/lib/gates.js +138 -0
- package/lib/install.js +140 -0
- package/lib/memory.js +34 -0
- package/lib/next-steps.js +50 -0
- package/lib/presets.js +176 -0
- package/lib/project-rules.js +210 -0
- package/lib/specs-utils.js +117 -0
- package/lib/token-cost.js +124 -0
- package/package.json +46 -0
- package/rules/engineering-baseline.mdc +56 -0
- package/scripts/_common.py +356 -0
- package/scripts/analyze_artifacts.py +187 -0
- package/scripts/check_commit.py +140 -0
- package/scripts/lessons.py +447 -0
- package/scripts/loop_plan.py +217 -0
- package/scripts/validate_spec.py +345 -0
- package/scripts/validate_state.py +385 -0
- package/scripts/validate_tasks.py +379 -0
- package/skills/agent-architecture.md +221 -0
- package/skills/appsec.md +83 -0
- package/skills/code-simplify.md +49 -0
- package/skills/engineering-standards.md +98 -0
- package/skills/git-handoff.md +213 -0
- package/skills/qa-strategy.md +83 -0
- package/skills/references/analyze.md +56 -0
- package/skills/references/archive.md +60 -0
- package/skills/references/constitution.md +66 -0
- package/skills/references/context-limits.md +73 -0
- package/skills/references/converge.md +47 -0
- package/skills/references/design.md +88 -0
- package/skills/references/discuss.md +68 -0
- package/skills/references/explore.md +61 -0
- package/skills/references/implement.md +175 -0
- package/skills/references/lessons.md +71 -0
- package/skills/references/memory.md +98 -0
- package/skills/references/project-init.md +62 -0
- package/skills/references/quick-mode.md +84 -0
- package/skills/references/specify.md +144 -0
- package/skills/references/sub-agents.md +117 -0
- package/skills/references/tasks.md +178 -0
- package/skills/references/validate.md +210 -0
- package/skills/security-review.md +120 -0
- package/skills/ship-ready.md +50 -0
- package/skills/task-graph-engineering.md +180 -0
- package/templates/GETTING_STARTED.md +61 -0
- package/templates/config.yaml.example +28 -0
- package/templates/presets/default.yaml +16 -0
- package/templates/presets/node-ts.yaml +22 -0
- package/templates/presets/python.yaml +22 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
const REQUIREMENT_HEADING =
|
|
2
|
+
/^(?<level>#{2,6})\s*(?<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\s*[:\-–]?\s*(?<title>.*)$/gm;
|
|
3
|
+
const ANY_HEADING = /^(?<level>#{1,6})\s+\S/m;
|
|
4
|
+
|
|
5
|
+
const DELTA_SECTIONS = [
|
|
6
|
+
"ADDED Requirements",
|
|
7
|
+
"MODIFIED Requirements",
|
|
8
|
+
"REMOVED Requirements",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const REMOVED_ID = /^\s*(?:-\s*)?(?<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b/gm;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isDeltaSpec(text) {
|
|
18
|
+
return DELTA_SECTIONS.some((heading) => hasSection(text, heading));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @param {string} heading
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function hasSection(text, heading) {
|
|
27
|
+
const pattern = new RegExp(`^#{1,6}\\s+${escapeRegExp(heading)}\\s*$`, "im");
|
|
28
|
+
return pattern.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} value
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function escapeRegExp(value) {
|
|
36
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} text
|
|
41
|
+
* @param {string} sectionHeading
|
|
42
|
+
* @returns {string | null}
|
|
43
|
+
*/
|
|
44
|
+
function sectionBody(text, sectionHeading) {
|
|
45
|
+
const headingPattern = new RegExp(
|
|
46
|
+
`^(?<level>#{2,6})\\s*${escapeRegExp(sectionHeading)}\\b`,
|
|
47
|
+
"im",
|
|
48
|
+
);
|
|
49
|
+
const match = headingPattern.exec(text);
|
|
50
|
+
if (!match) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const level = match[0].match(/^#+/)?.[0].length ?? 2;
|
|
55
|
+
const start = match.index + match[0].length;
|
|
56
|
+
let end = text.length;
|
|
57
|
+
|
|
58
|
+
const rest = text.slice(start);
|
|
59
|
+
for (const heading of rest.matchAll(/^(#{1,6})\s+\S/gm)) {
|
|
60
|
+
if (heading.index === undefined) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (heading[1].length <= level) {
|
|
64
|
+
end = start + heading.index;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return text.slice(start, end);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} scoped
|
|
74
|
+
* @returns {Map<string, string>}
|
|
75
|
+
*/
|
|
76
|
+
function extractRequirementBlocks(scoped) {
|
|
77
|
+
/** @type {Map<string, string>} */
|
|
78
|
+
const blocks = new Map();
|
|
79
|
+
|
|
80
|
+
const headingRe = new RegExp(REQUIREMENT_HEADING.source, REQUIREMENT_HEADING.flags);
|
|
81
|
+
let match = headingRe.exec(scoped);
|
|
82
|
+
|
|
83
|
+
while (match) {
|
|
84
|
+
const level = match.groups.level.length;
|
|
85
|
+
const id = match.groups.id;
|
|
86
|
+
const blockStart = match.index;
|
|
87
|
+
let blockEnd = scoped.length;
|
|
88
|
+
|
|
89
|
+
const scanRe = new RegExp(REQUIREMENT_HEADING.source, REQUIREMENT_HEADING.flags);
|
|
90
|
+
scanRe.lastIndex = match.index + match[0].length;
|
|
91
|
+
let next = scanRe.exec(scoped);
|
|
92
|
+
while (next) {
|
|
93
|
+
if (next.groups.level.length <= level) {
|
|
94
|
+
blockEnd = next.index;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
next = scanRe.exec(scoped);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const headingLine = scoped.slice(blockStart, match.index + match[0].length).split("\n")[0];
|
|
101
|
+
const body = scoped.slice(match.index + match[0].length, blockEnd).trimEnd();
|
|
102
|
+
blocks.set(id, `${headingLine}\n${body}`.trimEnd());
|
|
103
|
+
|
|
104
|
+
headingRe.lastIndex = blockEnd;
|
|
105
|
+
match = headingRe.exec(scoped);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return blocks;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @returns {Map<string, string>}
|
|
114
|
+
*/
|
|
115
|
+
export function extractDomainRequirements(text) {
|
|
116
|
+
const requirementsSection = sectionBody(text, "Requirements");
|
|
117
|
+
if (!requirementsSection) {
|
|
118
|
+
return new Map();
|
|
119
|
+
}
|
|
120
|
+
return extractRequirementBlocks(requirementsSection);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} domainSpec
|
|
125
|
+
* @param {Map<string, string>} requirements
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function writeDomainRequirements(domainSpec, requirements) {
|
|
129
|
+
const blocks = [...requirements.values()];
|
|
130
|
+
const requirementsBody = blocks.length ? `\n${blocks.join("\n\n")}\n` : "\n- none\n";
|
|
131
|
+
|
|
132
|
+
if (hasSection(domainSpec, "Requirements")) {
|
|
133
|
+
const headingPattern = /^#{2,6}\s*Requirements\b/im;
|
|
134
|
+
const match = headingPattern.exec(domainSpec);
|
|
135
|
+
if (!match) {
|
|
136
|
+
return domainSpec;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const level = match[0].match(/^#+/)?.[0] ?? "##";
|
|
140
|
+
const start = match.index;
|
|
141
|
+
const levelLen = level.length;
|
|
142
|
+
let end = domainSpec.length;
|
|
143
|
+
|
|
144
|
+
for (const heading of domainSpec.slice(start + match[0].length).matchAll(/^(#{1,6})\s+\S/gm)) {
|
|
145
|
+
if (heading.index === undefined) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (heading[1].length <= levelLen) {
|
|
149
|
+
end = start + match[0].length + heading.index;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
domainSpec.slice(0, start) +
|
|
156
|
+
`${level} Requirements${requirementsBody}` +
|
|
157
|
+
domainSpec.slice(end).replace(/^\n+/, "")
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const trimmed = domainSpec.trimEnd();
|
|
162
|
+
return `${trimmed}\n\n## Requirements${requirementsBody}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} scoped
|
|
167
|
+
* @returns {string[]}
|
|
168
|
+
*/
|
|
169
|
+
function extractRemovedIds(scoped) {
|
|
170
|
+
if (!scoped) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
if (/\bnone\b/i.test(scoped.trim())) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @type {string[]} */
|
|
178
|
+
const ids = [];
|
|
179
|
+
for (const match of scoped.matchAll(REMOVED_ID)) {
|
|
180
|
+
ids.push(match.groups.id);
|
|
181
|
+
}
|
|
182
|
+
return ids;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Merge a feature spec (full or delta) into a domain spec.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} domainSpec
|
|
189
|
+
* @param {string} featureSpec
|
|
190
|
+
* @returns {{ spec: string, summary: string[] }}
|
|
191
|
+
*/
|
|
192
|
+
export function mergeFeatureIntoDomain(domainSpec, featureSpec) {
|
|
193
|
+
const summary = [];
|
|
194
|
+
let requirements = extractDomainRequirements(domainSpec);
|
|
195
|
+
|
|
196
|
+
if (isDeltaSpec(featureSpec)) {
|
|
197
|
+
const added = sectionBody(featureSpec, "ADDED Requirements");
|
|
198
|
+
const modified = sectionBody(featureSpec, "MODIFIED Requirements");
|
|
199
|
+
const removed = sectionBody(featureSpec, "REMOVED Requirements");
|
|
200
|
+
|
|
201
|
+
for (const [id, block] of extractRequirementBlocks(added ?? "")) {
|
|
202
|
+
requirements.set(id, block);
|
|
203
|
+
summary.push(`ADDED ${id}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const [id, block] of extractRequirementBlocks(modified ?? "")) {
|
|
207
|
+
requirements.set(id, block);
|
|
208
|
+
summary.push(`MODIFIED ${id}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const id of extractRemovedIds(removed ?? "")) {
|
|
212
|
+
if (requirements.delete(id)) {
|
|
213
|
+
summary.push(`REMOVED ${id}`);
|
|
214
|
+
} else {
|
|
215
|
+
summary.push(`REMOVED ${id} (not in domain spec)`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const fullRequirements = sectionBody(featureSpec, "Requirements");
|
|
220
|
+
if (fullRequirements) {
|
|
221
|
+
requirements = extractRequirementBlocks(fullRequirements);
|
|
222
|
+
summary.push(`copied ${requirements.size} requirement(s) from full spec`);
|
|
223
|
+
} else {
|
|
224
|
+
summary.push("full spec has no Requirements section — domain spec unchanged");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
spec: writeDomainRequirements(domainSpec, requirements),
|
|
230
|
+
summary,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} domain
|
|
236
|
+
* @param {string} featureId
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function domainSpecStub(domain, featureId) {
|
|
240
|
+
return `# Domain: ${domain}
|
|
241
|
+
|
|
242
|
+
> Seeded by archive-feature from \`${featureId}\`.
|
|
243
|
+
|
|
244
|
+
## Requirements
|
|
245
|
+
|
|
246
|
+
- none
|
|
247
|
+
`;
|
|
248
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { NPX, SKILL_DIRS } from "./constants.js";
|
|
7
|
+
import { resolveScriptsDir } from "./gates.js";
|
|
8
|
+
import { readFileSafe } from "./fs-utils.js";
|
|
9
|
+
import { listFeatureIds } from "./specs-utils.js";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* id: string,
|
|
16
|
+
* label: string,
|
|
17
|
+
* weight: number,
|
|
18
|
+
* pass: boolean,
|
|
19
|
+
* suggest?: string,
|
|
20
|
+
* optional?: boolean,
|
|
21
|
+
* }} DoctorCheck
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} cwd
|
|
26
|
+
* @param {string} relativePath
|
|
27
|
+
* @returns {Promise<boolean>}
|
|
28
|
+
*/
|
|
29
|
+
async function pathExists(cwd, relativePath) {
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(path.join(cwd, relativePath));
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} cwd
|
|
40
|
+
* @returns {Promise<boolean>}
|
|
41
|
+
*/
|
|
42
|
+
async function pythonAvailable() {
|
|
43
|
+
for (const bin of ["python3", "python"]) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync(bin, ["--version"]);
|
|
46
|
+
const match = stdout.match(/(\d+)\.(\d+)/);
|
|
47
|
+
if (!match) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const major = Number(match[1]);
|
|
51
|
+
const minor = Number(match[2]);
|
|
52
|
+
if (major > 3 || (major === 3 && minor >= 10)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// try next binary
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} cwd
|
|
64
|
+
* @returns {Promise<string | null>}
|
|
65
|
+
*/
|
|
66
|
+
async function readActiveFeature(cwd) {
|
|
67
|
+
try {
|
|
68
|
+
const state = await readFileSafe(path.join(cwd, ".specs/STATE.md"));
|
|
69
|
+
const match = state.match(/^\s*[-*]\s*\*\*Active feature\*\*:\s*(.+)$/im);
|
|
70
|
+
if (!match) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const value = match[1].trim();
|
|
74
|
+
if (!value || /^none|idle|—|-$/i.test(value)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return value.replace(/^`|`$/g, "");
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} cwd
|
|
85
|
+
* @returns {Promise<DoctorCheck[]>}
|
|
86
|
+
*/
|
|
87
|
+
export async function runDoctorChecks(cwd) {
|
|
88
|
+
/** @type {DoctorCheck[]} */
|
|
89
|
+
const checks = [];
|
|
90
|
+
|
|
91
|
+
const hubInstalled = (
|
|
92
|
+
await Promise.all(
|
|
93
|
+
SKILL_DIRS.map((dir) => pathExists(cwd, path.join(dir, "agent-architecture.md"))),
|
|
94
|
+
)
|
|
95
|
+
).some(Boolean);
|
|
96
|
+
|
|
97
|
+
checks.push({
|
|
98
|
+
id: "skills-hub",
|
|
99
|
+
label: "Agent hub skill (agent-architecture.md)",
|
|
100
|
+
weight: 12,
|
|
101
|
+
pass: hubInstalled,
|
|
102
|
+
suggest: NPX("install"),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const scriptsDir = await resolveScriptsDir(cwd);
|
|
106
|
+
const gatesDir = path.join(cwd, scriptsDir);
|
|
107
|
+
let gatesPresent = false;
|
|
108
|
+
try {
|
|
109
|
+
const entries = await fs.readdir(gatesDir);
|
|
110
|
+
gatesPresent = entries.some((name) => name.endsWith(".py"));
|
|
111
|
+
} catch {
|
|
112
|
+
gatesPresent = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
checks.push({
|
|
116
|
+
id: "gate-scripts",
|
|
117
|
+
label: `Python gate scripts under ${scriptsDir}/`,
|
|
118
|
+
weight: 15,
|
|
119
|
+
pass: gatesPresent,
|
|
120
|
+
suggest: NPX("install"),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const pythonOk = await pythonAvailable();
|
|
124
|
+
checks.push({
|
|
125
|
+
id: "python",
|
|
126
|
+
label: "Python 3.10+ available for gates",
|
|
127
|
+
weight: 8,
|
|
128
|
+
pass: pythonOk,
|
|
129
|
+
suggest: "Install Python 3.10+ or run gate checklists manually from skills/references/",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const specsScaffold =
|
|
133
|
+
(await pathExists(cwd, ".specs/STATE.md")) &&
|
|
134
|
+
(await pathExists(cwd, ".specs/features")) &&
|
|
135
|
+
(await pathExists(cwd, ".specs/project"));
|
|
136
|
+
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "specs-scaffold",
|
|
139
|
+
label: ".specs/ memory scaffold (STATE, features/, project/)",
|
|
140
|
+
weight: 10,
|
|
141
|
+
pass: specsScaffold,
|
|
142
|
+
suggest: NPX("install"),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
checks.push({
|
|
146
|
+
id: "config",
|
|
147
|
+
label: ".specs/config.yaml project config",
|
|
148
|
+
weight: 10,
|
|
149
|
+
pass: await pathExists(cwd, ".specs/config.yaml"),
|
|
150
|
+
suggest: NPX("init-config --preset default"),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
checks.push({
|
|
154
|
+
id: "baseline-rule",
|
|
155
|
+
label: "engineering-baseline.mdc always-on rule",
|
|
156
|
+
weight: 8,
|
|
157
|
+
pass: await pathExists(cwd, ".cursor/rules/engineering-baseline.mdc"),
|
|
158
|
+
suggest: NPX("install"),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const hasProjectMd = await pathExists(cwd, ".specs/project/PROJECT.md");
|
|
162
|
+
checks.push({
|
|
163
|
+
id: "project-context",
|
|
164
|
+
label: "PROJECT.md brownfield context",
|
|
165
|
+
weight: 10,
|
|
166
|
+
pass: hasProjectMd,
|
|
167
|
+
optional: true,
|
|
168
|
+
suggest: NPX("project-init"),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const activeFeature = await readActiveFeature(cwd);
|
|
172
|
+
let activeFeatureOk = true;
|
|
173
|
+
let activeFeatureSuggest;
|
|
174
|
+
|
|
175
|
+
if (activeFeature) {
|
|
176
|
+
const features = await listFeatureIds(cwd);
|
|
177
|
+
activeFeatureOk = features.includes(activeFeature);
|
|
178
|
+
if (!activeFeatureOk) {
|
|
179
|
+
activeFeatureSuggest = `Update .specs/STATE.md or run feature-init — "${activeFeature}" not found under .specs/features/`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
checks.push({
|
|
184
|
+
id: "state-feature",
|
|
185
|
+
label: "STATE.md active feature matches .specs/features/",
|
|
186
|
+
weight: 5,
|
|
187
|
+
pass: activeFeatureOk,
|
|
188
|
+
suggest: activeFeatureSuggest,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let gateSmoke = false;
|
|
192
|
+
if (gatesPresent && pythonOk) {
|
|
193
|
+
try {
|
|
194
|
+
const script = path.join(cwd, scriptsDir, "check_commit.py");
|
|
195
|
+
await execFileAsync("python3", [
|
|
196
|
+
script,
|
|
197
|
+
"--message",
|
|
198
|
+
"chore(guardrails): doctor smoke test",
|
|
199
|
+
], { cwd });
|
|
200
|
+
gateSmoke = true;
|
|
201
|
+
} catch {
|
|
202
|
+
gateSmoke = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
checks.push({
|
|
207
|
+
id: "gate-smoke",
|
|
208
|
+
label: "check-commit gate runs",
|
|
209
|
+
weight: 12,
|
|
210
|
+
pass: gateSmoke,
|
|
211
|
+
suggest: gatesPresent
|
|
212
|
+
? "Fix Python gate install or run: python3 .specs/guardrails/scripts/check_commit.py --message \"test: smoke\""
|
|
213
|
+
: NPX("install"),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (activeFeature) {
|
|
217
|
+
const featureDir = path.join(cwd, ".specs/features", activeFeature);
|
|
218
|
+
const tasksPath = path.join(featureDir, "tasks.md");
|
|
219
|
+
let taskCount = 0;
|
|
220
|
+
let needsGraph = false;
|
|
221
|
+
let hasGraph = false;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const tasks = await readFileSafe(tasksPath);
|
|
225
|
+
taskCount = (tasks.match(/^#{2,6}\s*T\d+/gim) ?? []).length;
|
|
226
|
+
needsGraph = taskCount >= 3;
|
|
227
|
+
hasGraph = await pathExists(cwd, path.join(".specs/features", activeFeature, "task-graph.md"));
|
|
228
|
+
} catch {
|
|
229
|
+
needsGraph = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (needsGraph) {
|
|
233
|
+
checks.push({
|
|
234
|
+
id: "task-graph",
|
|
235
|
+
label: `task-graph.md for active feature (${taskCount} tasks)`,
|
|
236
|
+
weight: 10,
|
|
237
|
+
pass: hasGraph,
|
|
238
|
+
suggest: `Draw the DAG in .specs/features/${activeFeature}/task-graph.md (see task-graph-engineering.md)`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return checks;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Contextual Execute hint when the active feature has tasks.md.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} cwd
|
|
250
|
+
* @param {string | null} activeFeature
|
|
251
|
+
* @returns {Promise<string | null>}
|
|
252
|
+
*/
|
|
253
|
+
export async function resolveExecuteHint(cwd, activeFeature) {
|
|
254
|
+
if (!activeFeature) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const tasksPath = path.join(cwd, ".specs/features", activeFeature, "tasks.md");
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const tasks = await readFileSafe(tasksPath);
|
|
262
|
+
const taskIds = tasks.match(/^#{2,6}\s*T\d+/gim) ?? [];
|
|
263
|
+
if (taskIds.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const completeCount = (tasks.match(/-\s*\[x\]\s*complete\b/gi) ?? []).length;
|
|
268
|
+
if (completeCount >= taskIds.length) {
|
|
269
|
+
return `${NPX(`validate-state ${activeFeature}`)} — tasks look complete; run Validate before /verify`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return `${NPX(`loop-plan ${activeFeature}`)} — next Execute wave (then /loop in chat)`;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {DoctorCheck[]} checks
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
export function scoreDoctorChecks(checks) {
|
|
283
|
+
const scored = checks.filter((check) => !check.optional);
|
|
284
|
+
const earned = scored.filter((check) => check.pass).reduce((sum, check) => sum + check.weight, 0);
|
|
285
|
+
const total = scored.reduce((sum, check) => sum + check.weight, 0);
|
|
286
|
+
if (total === 0) {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
return Math.round((earned / total) * 100);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {DoctorCheck[]} checks
|
|
294
|
+
* @param {number} limit
|
|
295
|
+
* @returns {DoctorCheck[]}
|
|
296
|
+
*/
|
|
297
|
+
export function topDoctorSuggestions(checks, limit = 3) {
|
|
298
|
+
return checks.filter((check) => !check.pass && check.suggest).slice(0, limit);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} cwd
|
|
303
|
+
* @param {{ suggest?: boolean, json?: boolean }} [options]
|
|
304
|
+
* @returns {Promise<{ score: number, checks: DoctorCheck[], suggestions: DoctorCheck[], executeHint: string | null }>}
|
|
305
|
+
*/
|
|
306
|
+
export async function doctor(cwd, options = {}) {
|
|
307
|
+
const checks = await runDoctorChecks(cwd);
|
|
308
|
+
const score = scoreDoctorChecks(checks);
|
|
309
|
+
const suggestions = topDoctorSuggestions(checks);
|
|
310
|
+
const activeFeature = await readActiveFeature(cwd);
|
|
311
|
+
const executeHint = await resolveExecuteHint(cwd, activeFeature);
|
|
312
|
+
|
|
313
|
+
if (options.json) {
|
|
314
|
+
console.log(
|
|
315
|
+
JSON.stringify({ score, checks, suggestions, executeHint }, null, 2),
|
|
316
|
+
);
|
|
317
|
+
return { score, checks, suggestions, executeHint };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log(`Guardrails Ready: ${score}/100\n`);
|
|
321
|
+
|
|
322
|
+
for (const check of checks) {
|
|
323
|
+
const mark = check.pass ? "✓" : "✗";
|
|
324
|
+
const optional = check.optional ? " (optional)" : "";
|
|
325
|
+
console.log(`${mark} ${check.label}${optional}`);
|
|
326
|
+
if (!check.pass && check.suggest && options.suggest !== false) {
|
|
327
|
+
console.log(` → ${check.suggest}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (suggestions.length > 0) {
|
|
332
|
+
console.log("\nTop next actions:");
|
|
333
|
+
suggestions.forEach((check, index) => {
|
|
334
|
+
console.log(`${index + 1}. ${check.suggest}`);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (executeHint) {
|
|
339
|
+
console.log(`\nExecute hint:\n → ${executeHint}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { score, checks, suggestions, executeHint };
|
|
343
|
+
}
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { Readable, Transform } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
|
|
5
|
+
import { assertSafeDownloadUrl } from "./constants.js";
|
|
6
|
+
import { assertSafeWriteTarget, removeFileSafe } from "./fs-utils.js";
|
|
7
|
+
|
|
8
|
+
/** A stalled mirror should fail the install instead of hanging it. */
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
/** Harness assets are markdown and small scripts; anything larger is suspect. */
|
|
12
|
+
const MAX_ASSET_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Cap redirect chains so a malicious mirror cannot loop forever. */
|
|
15
|
+
const MAX_REDIRECTS = 10;
|
|
16
|
+
|
|
17
|
+
function limitSize(url, limit) {
|
|
18
|
+
let received = 0;
|
|
19
|
+
|
|
20
|
+
return new Transform({
|
|
21
|
+
transform(chunk, _encoding, callback) {
|
|
22
|
+
received += chunk.length;
|
|
23
|
+
|
|
24
|
+
if (received > limit) {
|
|
25
|
+
callback(
|
|
26
|
+
new Error(
|
|
27
|
+
`Download failed: ${url} exceeds the ${limit}-byte asset limit`,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
callback(null, chunk);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Follow redirects manually so every hop re-validates the HTTPS/localhost policy.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} startUrl
|
|
42
|
+
* @returns {Promise<Response>}
|
|
43
|
+
*/
|
|
44
|
+
async function fetchWithSafeRedirects(startUrl) {
|
|
45
|
+
let current = assertSafeDownloadUrl(startUrl);
|
|
46
|
+
|
|
47
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
48
|
+
let response;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
response = await fetch(current, {
|
|
52
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
53
|
+
redirect: "manual",
|
|
54
|
+
});
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Download failed: ${current} timed out after ${REQUEST_TIMEOUT_MS}ms`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Download failed: unable to reach ${current} (${err.message})`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (response.status >= 300 && response.status < 400) {
|
|
67
|
+
const location = response.headers.get("location");
|
|
68
|
+
if (!location) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Download failed: redirect ${response.status} from ${current} with no Location`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let next;
|
|
75
|
+
try {
|
|
76
|
+
next = new URL(location, current).href;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Download failed: invalid redirect Location from ${current}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
current = assertSafeDownloadUrl(next, current);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Download failed: redirect from ${startUrl} to disallowed URL (${err.message})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return response;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Download failed: too many redirects while fetching ${startUrl}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function downloadToFile(url, destPath) {
|
|
102
|
+
await assertSafeWriteTarget(destPath);
|
|
103
|
+
|
|
104
|
+
const response = await fetchWithSafeRedirects(url);
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Download failed: ${response.status} ${url}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!response.body) {
|
|
111
|
+
throw new Error(`Download failed: empty response body from ${url}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
115
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_ASSET_BYTES) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Download failed: ${url} exceeds the ${MAX_ASSET_BYTES}-byte asset limit`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const body = Readable.fromWeb(response.body);
|
|
122
|
+
const fileStream = createWriteStream(destPath);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await pipeline(body, limitSize(url, MAX_ASSET_BYTES), fileStream);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
await removeFileSafe(destPath);
|
|
128
|
+
if (err.code === "EACCES" || err.code === "EPERM") {
|
|
129
|
+
throw new Error(`Permission denied: cannot write ${destPath}`);
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|