@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,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing messages after install — keep CLI surface minimal.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{ pythonAvailable?: boolean, preset?: string }} [options]
|
|
7
|
+
* @returns {string[]}
|
|
8
|
+
*/
|
|
9
|
+
export function formatInstallNextSteps(options = {}) {
|
|
10
|
+
const lines = [
|
|
11
|
+
"",
|
|
12
|
+
"✨ Setup complete.",
|
|
13
|
+
"",
|
|
14
|
+
"Next:",
|
|
15
|
+
" 1. Open Cursor or Claude Code in this project.",
|
|
16
|
+
" 2. Run **Specify** (`/specify` or “Specify a feature: …”).",
|
|
17
|
+
"",
|
|
18
|
+
" Guide: docs/guide/Quick-start.md (repo) · .specs/GETTING_STARTED.md (this project)",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
if (options.preset) {
|
|
22
|
+
lines.push(` Config: .specs/config.yaml (preset: ${options.preset})`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.pythonAvailable === false) {
|
|
26
|
+
lines.push(
|
|
27
|
+
" Note: install Python 3.10+ for automatic gates, or the agent checks by hand.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lines.push(
|
|
32
|
+
"",
|
|
33
|
+
"Optional CLI (you rarely need these on day one):",
|
|
34
|
+
" project-init existing repo with code already",
|
|
35
|
+
" doctor if something looks wrong",
|
|
36
|
+
" --help full command list",
|
|
37
|
+
"",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ pythonAvailable?: boolean, preset?: string }} [options]
|
|
45
|
+
*/
|
|
46
|
+
export function printInstallNextSteps(options = {}) {
|
|
47
|
+
for (const line of formatInstallNextSteps(options)) {
|
|
48
|
+
console.log(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/lib/presets.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { mergeGuardrailsConfigs, parseGuardrailsConfig, resolveGuardrailsConfig } from "./config.js";
|
|
6
|
+
import { ensureDir, readFileSafe, writeFileIfMissing, writeFileSafe } from "./fs-utils.js";
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(
|
|
9
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const PRESETS_DIR = "templates/presets";
|
|
13
|
+
export const CONFIG_PATH = ".specs/config.yaml";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} name
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function presetAssetPath(name) {
|
|
20
|
+
return path.join(PACKAGE_ROOT, PRESETS_DIR, `${name}.yaml`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {Promise<string[]>}
|
|
25
|
+
*/
|
|
26
|
+
export async function listPresets() {
|
|
27
|
+
const dir = path.join(PACKAGE_ROOT, PRESETS_DIR);
|
|
28
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
29
|
+
return entries
|
|
30
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".yaml"))
|
|
31
|
+
.map((entry) => entry.name.replace(/\.yaml$/, ""))
|
|
32
|
+
.sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} name
|
|
37
|
+
* @returns {Promise<string>}
|
|
38
|
+
*/
|
|
39
|
+
export async function loadPresetText(name) {
|
|
40
|
+
const presets = await listPresets();
|
|
41
|
+
if (!presets.includes(name)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Unknown preset "${name}". Available: ${presets.join(", ")}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return readFileSafe(presetAssetPath(name));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} name
|
|
52
|
+
*/
|
|
53
|
+
export async function loadPreset(name) {
|
|
54
|
+
const text = await loadPresetText(name);
|
|
55
|
+
return parseGuardrailsConfig(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {ReturnType<typeof parseGuardrailsConfig>} document
|
|
60
|
+
* @param {Set<string>} [seen]
|
|
61
|
+
* @returns {Promise<ReturnType<typeof parseGuardrailsConfig>>}
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveConfigDocument(document, seen = new Set()) {
|
|
64
|
+
return resolveGuardrailsConfig(document, loadPreset, seen);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} [cwd]
|
|
69
|
+
* @returns {Promise<ReturnType<typeof parseGuardrailsConfig> | null>}
|
|
70
|
+
*/
|
|
71
|
+
export async function loadResolvedConfig(cwd = process.cwd()) {
|
|
72
|
+
const configPath = path.join(cwd, CONFIG_PATH);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const text = await readFileSafe(configPath);
|
|
76
|
+
const document = parseGuardrailsConfig(text);
|
|
77
|
+
return resolveConfigDocument(document);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err.code === "ENOENT" || /cannot read/i.test(err.message)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} preset
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function configFromPreset(preset) {
|
|
91
|
+
if (preset === "default") {
|
|
92
|
+
return `# Project guardrails config (preset: default)
|
|
93
|
+
# Copy/edit freely. See templates/presets/ for other stacks.
|
|
94
|
+
|
|
95
|
+
schema: spec-driven
|
|
96
|
+
|
|
97
|
+
context: |
|
|
98
|
+
Tech stack: (fill in)
|
|
99
|
+
Test command: npm test
|
|
100
|
+
Branch prefix: feat
|
|
101
|
+
|
|
102
|
+
rules:
|
|
103
|
+
specify:
|
|
104
|
+
- Prefer EARS acceptance criteria
|
|
105
|
+
- Mark unknowns with [NEEDS CLARIFICATION: question]
|
|
106
|
+
tasks:
|
|
107
|
+
- Every REQ must appear in the Test Coverage Matrix
|
|
108
|
+
verify:
|
|
109
|
+
- Evidence must cite test file:line paths
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return `# Project guardrails config (extends preset: ${preset})
|
|
114
|
+
extends: ${preset}
|
|
115
|
+
|
|
116
|
+
# Optional project context (appended to preset):
|
|
117
|
+
# context: |
|
|
118
|
+
# Team name, repo quirks, etc.
|
|
119
|
+
|
|
120
|
+
# Optional overrides (appended on top of preset rules):
|
|
121
|
+
# overrides:
|
|
122
|
+
# rules:
|
|
123
|
+
# specify:
|
|
124
|
+
# - Your extra rule on top of the preset
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {{ preset?: string, cwd?: string, force?: boolean }} [options]
|
|
130
|
+
*/
|
|
131
|
+
export async function initProjectConfig(options = {}) {
|
|
132
|
+
const cwd = options.cwd ?? process.cwd();
|
|
133
|
+
const preset = options.preset ?? "default";
|
|
134
|
+
const presets = await listPresets();
|
|
135
|
+
|
|
136
|
+
if (!presets.includes(preset)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Unknown preset "${preset}". Available: ${presets.join(", ")}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await ensureDir(path.join(cwd, ".specs"));
|
|
143
|
+
const configPath = path.join(cwd, CONFIG_PATH);
|
|
144
|
+
const content = configFromPreset(preset);
|
|
145
|
+
|
|
146
|
+
if (options.force) {
|
|
147
|
+
await writeFileSafe(configPath, content);
|
|
148
|
+
return { created: false, updated: true, path: CONFIG_PATH, preset };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const created = await writeFileIfMissing(configPath, content);
|
|
152
|
+
return {
|
|
153
|
+
created,
|
|
154
|
+
updated: false,
|
|
155
|
+
path: CONFIG_PATH,
|
|
156
|
+
preset,
|
|
157
|
+
skipped: !created,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Read branch prefix from resolved config (explicit field or context line).
|
|
163
|
+
*
|
|
164
|
+
* @param {ReturnType<typeof parseGuardrailsConfig> | null | undefined} config
|
|
165
|
+
* @returns {string | undefined}
|
|
166
|
+
*/
|
|
167
|
+
export function readBranchPrefix(config) {
|
|
168
|
+
if (config?.branch_prefix?.trim()) {
|
|
169
|
+
return config.branch_prefix.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const match = config?.context?.match(/^Branch prefix:\s*(\S+)/im);
|
|
173
|
+
return match?.[1];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { mergeGuardrailsConfigs };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CURSOR_RULES_DIR, RULE_ASSETS } from "./constants.js";
|
|
5
|
+
import { ensureDir } from "./fs-utils.js";
|
|
6
|
+
|
|
7
|
+
/** Markers around the catalog skills table — refreshed on every install. */
|
|
8
|
+
export const SKILLS_MAP_START = "<!-- guardrails-managed:skills-map:start -->";
|
|
9
|
+
export const SKILLS_MAP_END = "<!-- guardrails-managed:skills-map:end -->";
|
|
10
|
+
|
|
11
|
+
/** Markers around the gates table — refreshed on every install. */
|
|
12
|
+
export const GATES_MAP_START = "<!-- guardrails-managed:gates-map:start -->";
|
|
13
|
+
export const GATES_MAP_END = "<!-- guardrails-managed:gates-map:end -->";
|
|
14
|
+
|
|
15
|
+
/** Prior managed markers — recognized only so `install` can refresh tables. */
|
|
16
|
+
const LEGACY_SKILLS_MAP_PAIRS = [
|
|
17
|
+
["<!-- seatbelt-managed:skills-map:start -->", "<!-- seatbelt-managed:skills-map:end -->"],
|
|
18
|
+
["<!-- harness-managed:skills-map:start -->", "<!-- harness-managed:skills-map:end -->"],
|
|
19
|
+
];
|
|
20
|
+
const LEGACY_GATES_MAP_PAIRS = [
|
|
21
|
+
["<!-- seatbelt-managed:gates-map:start -->", "<!-- seatbelt-managed:gates-map:end -->"],
|
|
22
|
+
["<!-- harness-managed:gates-map:start -->", "<!-- harness-managed:gates-map:end -->"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} content
|
|
27
|
+
* @param {string} startMarker
|
|
28
|
+
* @param {string} endMarker
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
export function extractManagedBlock(content, startMarker, endMarker) {
|
|
32
|
+
const start = content.indexOf(startMarker);
|
|
33
|
+
const end = content.indexOf(endMarker);
|
|
34
|
+
if (start < 0 || end < 0 || end < start) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return content.slice(start, end + endMarker.length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} content
|
|
42
|
+
* @param {[string, string][]} markerPairs
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
function extractFirstManagedBlock(content, markerPairs) {
|
|
46
|
+
for (const [startMarker, endMarker] of markerPairs) {
|
|
47
|
+
const block = extractManagedBlock(content, startMarker, endMarker);
|
|
48
|
+
if (block) {
|
|
49
|
+
return block;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} content
|
|
57
|
+
* @returns {string | null}
|
|
58
|
+
*/
|
|
59
|
+
export function extractSkillsMapBlock(content) {
|
|
60
|
+
return extractFirstManagedBlock(content, [
|
|
61
|
+
[SKILLS_MAP_START, SKILLS_MAP_END],
|
|
62
|
+
...LEGACY_SKILLS_MAP_PAIRS,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} content
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function extractGatesMapBlock(content) {
|
|
71
|
+
return extractFirstManagedBlock(content, [
|
|
72
|
+
[GATES_MAP_START, GATES_MAP_END],
|
|
73
|
+
...LEGACY_GATES_MAP_PAIRS,
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace a managed block when present in `existing`, otherwise append it.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} existing
|
|
81
|
+
* @param {string} shippedBlock
|
|
82
|
+
* @param {string} startMarker
|
|
83
|
+
* @param {string} endMarker
|
|
84
|
+
* @param {[string, string][]} legacyMarkerPairs
|
|
85
|
+
* @param {string} [appendHeading]
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function mergeManagedBlock(
|
|
89
|
+
existing,
|
|
90
|
+
shippedBlock,
|
|
91
|
+
startMarker,
|
|
92
|
+
endMarker,
|
|
93
|
+
legacyMarkerPairs,
|
|
94
|
+
appendHeading = "",
|
|
95
|
+
) {
|
|
96
|
+
for (const [legacyStart, legacyEnd] of legacyMarkerPairs) {
|
|
97
|
+
const legacyBlock = extractManagedBlock(existing, legacyStart, legacyEnd);
|
|
98
|
+
if (legacyBlock) {
|
|
99
|
+
return existing.replace(legacyBlock, shippedBlock);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const existingBlock = extractManagedBlock(existing, startMarker, endMarker);
|
|
104
|
+
if (existingBlock) {
|
|
105
|
+
return existing.replace(existingBlock, shippedBlock);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (appendHeading) {
|
|
109
|
+
return `${existing.trimEnd()}\n\n${appendHeading}\n\n${shippedBlock}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `${existing.trimEnd()}\n\n${shippedBlock}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge shipped baseline into an existing rule file without wiping user prose.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} existing
|
|
119
|
+
* @param {string} shipped
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function mergeBaselineRule(existing, shipped) {
|
|
123
|
+
const shippedSkills = extractSkillsMapBlock(shipped);
|
|
124
|
+
const shippedGates = extractGatesMapBlock(shipped);
|
|
125
|
+
if (!shippedSkills && !shippedGates) {
|
|
126
|
+
return shipped;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let merged = existing;
|
|
130
|
+
|
|
131
|
+
if (shippedSkills) {
|
|
132
|
+
const skillsSection = /^# (?:Harness|Seatbelt|Guardrails) Skills\b[\s\S]*?(?=^# |\Z)/m;
|
|
133
|
+
if (
|
|
134
|
+
!extractSkillsMapBlock(merged) &&
|
|
135
|
+
skillsSection.test(merged) &&
|
|
136
|
+
shipped.match(/^# (?:Harness|Seatbelt|Guardrails) Skills\b[\s\S]*?(?=^# |\Z)/m)
|
|
137
|
+
) {
|
|
138
|
+
const shippedSection = shipped.match(/^# (?:Harness|Seatbelt|Guardrails) Skills\b[\s\S]*?(?=^# |\Z)/m);
|
|
139
|
+
if (shippedSection) {
|
|
140
|
+
merged = merged.replace(skillsSection, shippedSection[0]);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
merged = mergeManagedBlock(
|
|
144
|
+
merged,
|
|
145
|
+
shippedSkills,
|
|
146
|
+
SKILLS_MAP_START,
|
|
147
|
+
SKILLS_MAP_END,
|
|
148
|
+
LEGACY_SKILLS_MAP_PAIRS,
|
|
149
|
+
extractSkillsMapBlock(merged) ? "" : "# Guardrails Skills",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (shippedGates) {
|
|
155
|
+
merged = mergeManagedBlock(
|
|
156
|
+
merged,
|
|
157
|
+
shippedGates,
|
|
158
|
+
GATES_MAP_START,
|
|
159
|
+
GATES_MAP_END,
|
|
160
|
+
LEGACY_GATES_MAP_PAIRS,
|
|
161
|
+
extractGatesMapBlock(merged) ? "" : "# Deterministic Gates",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Install Cursor project rules (.cursor/rules/*.mdc).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} cwd
|
|
172
|
+
* @param {{ fetchAsset: (remotePath: string, destPath: string) => Promise<void> }} options
|
|
173
|
+
*/
|
|
174
|
+
export async function installProjectRules(cwd, options) {
|
|
175
|
+
const rulesDir = path.join(cwd, CURSOR_RULES_DIR);
|
|
176
|
+
await ensureDir(rulesDir);
|
|
177
|
+
|
|
178
|
+
for (const rule of RULE_ASSETS) {
|
|
179
|
+
const destPath = path.join(rulesDir, rule.file);
|
|
180
|
+
const tmpPath = path.join(rulesDir, `.${rule.file}.incoming`);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await options.fetchAsset(rule.remotePath, tmpPath);
|
|
184
|
+
const shipped = await fs.readFile(tmpPath, "utf8");
|
|
185
|
+
|
|
186
|
+
let exists = false;
|
|
187
|
+
try {
|
|
188
|
+
await fs.access(destPath);
|
|
189
|
+
exists = true;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err.code !== "ENOENT") {
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!exists) {
|
|
197
|
+
await fs.rename(tmpPath, destPath);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const existing = await fs.readFile(destPath, "utf8");
|
|
202
|
+
const merged = mergeBaselineRule(existing, shipped);
|
|
203
|
+
await fs.writeFile(destPath, merged, "utf8");
|
|
204
|
+
await fs.rm(tmpPath, { force: true });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readFileSafe } from "./fs-utils.js";
|
|
5
|
+
|
|
6
|
+
const FEATURES_DIR = ".specs/features";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} cwd
|
|
10
|
+
* @returns {Promise<string[]>}
|
|
11
|
+
*/
|
|
12
|
+
export async function listFeatureIds(cwd) {
|
|
13
|
+
const featuresRoot = path.join(cwd, FEATURES_DIR);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const entries = await fs.readdir(featuresRoot, { withFileTypes: true });
|
|
17
|
+
return entries
|
|
18
|
+
.filter((entry) => entry.isDirectory())
|
|
19
|
+
.map((entry) => entry.name)
|
|
20
|
+
.sort();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.code === "ENOENT") {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string | undefined} raw
|
|
31
|
+
* @param {string} cwd
|
|
32
|
+
* @returns {Promise<string>}
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveFeatureId(raw, cwd) {
|
|
35
|
+
if (raw?.trim()) {
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
const asPath = path.resolve(cwd, trimmed);
|
|
38
|
+
|
|
39
|
+
if (trimmed.endsWith(".md") || trimmed.includes("/")) {
|
|
40
|
+
const relative = path.relative(path.join(cwd, FEATURES_DIR), asPath);
|
|
41
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
42
|
+
return relative.split(path.sep)[0];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const named = path.join(cwd, FEATURES_DIR, trimmed);
|
|
47
|
+
try {
|
|
48
|
+
const stat = await fs.stat(named);
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// fall through
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(`No such feature or path: ${trimmed}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const active = await readActiveFeatureFromState(cwd);
|
|
60
|
+
if (active && active !== "—") {
|
|
61
|
+
return active;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const features = await listFeatureIds(cwd);
|
|
65
|
+
if (features.length === 1) {
|
|
66
|
+
return features[0];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (features.length === 0) {
|
|
70
|
+
throw new Error("No features found — create .specs/features/[feature]/ first.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${features.length} features found — name the one to use:\n` +
|
|
75
|
+
features.map((id) => ` ${id}`).join("\n"),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} cwd
|
|
81
|
+
* @returns {Promise<string | null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function readActiveFeatureFromState(cwd) {
|
|
84
|
+
const statePath = path.join(cwd, ".specs/STATE.md");
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const content = await readFileSafe(statePath);
|
|
88
|
+
const match = content.match(/^-\s*Feature:\s*(.+)$/m);
|
|
89
|
+
if (!match) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const value = match[1].trim();
|
|
93
|
+
return value === "—" ? null : value;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} featureId
|
|
101
|
+
* @param {string} cwd
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function featureDir(featureId, cwd) {
|
|
105
|
+
return path.join(cwd, FEATURES_DIR, featureId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} featureId
|
|
110
|
+
* @param {string} cwd
|
|
111
|
+
* @param {string} filename
|
|
112
|
+
* @returns {Promise<string>}
|
|
113
|
+
*/
|
|
114
|
+
export async function readFeatureArtifact(featureId, cwd, filename) {
|
|
115
|
+
const filePath = path.join(featureDir(featureId, cwd), filename);
|
|
116
|
+
return readFileSafe(filePath);
|
|
117
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { packagedAssetPath } from "./assets.js";
|
|
4
|
+
import {
|
|
5
|
+
REFERENCE_ASSETS,
|
|
6
|
+
RULE_ASSETS,
|
|
7
|
+
SKILL_ASSETS,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
|
|
10
|
+
/** Rough English/code heuristic (~4 characters per token). Not a billing API. */
|
|
11
|
+
export const CHARS_PER_TOKEN = 4;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
export function estimateTokens(text) {
|
|
18
|
+
if (!text) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} remotePath
|
|
26
|
+
* @returns {Promise<string>}
|
|
27
|
+
*/
|
|
28
|
+
async function readPackagedText(remotePath) {
|
|
29
|
+
return fs.readFile(packagedAssetPath(remotePath), "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string[]} remotePaths
|
|
34
|
+
* @returns {Promise<{ chars: number, tokens: number, files: number }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function measureBundle(remotePaths) {
|
|
37
|
+
let chars = 0;
|
|
38
|
+
for (const remotePath of remotePaths) {
|
|
39
|
+
const text = await readPackagedText(remotePath);
|
|
40
|
+
chars += text.length;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
chars,
|
|
44
|
+
tokens: estimateTokens(" ".repeat(chars)),
|
|
45
|
+
files: remotePaths.length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ALL_SISTER_SKILLS = SKILL_ASSETS.map((asset) => asset.remotePath);
|
|
50
|
+
const ALL_REFERENCES = REFERENCE_ASSETS.map((asset) => asset.remotePath);
|
|
51
|
+
const BASELINE_RULE = RULE_ASSETS[0].remotePath;
|
|
52
|
+
|
|
53
|
+
/** Documented load profiles — mirror agent-architecture progressive disclosure. */
|
|
54
|
+
export const LOAD_PROFILES = {
|
|
55
|
+
naiveFullDump: {
|
|
56
|
+
label: "Naive full dump (everything every turn)",
|
|
57
|
+
paths: [...ALL_SISTER_SKILLS, ...ALL_REFERENCES, BASELINE_RULE],
|
|
58
|
+
},
|
|
59
|
+
specifyTurn: {
|
|
60
|
+
label: "Specify turn (hub + phase + standards)",
|
|
61
|
+
paths: [
|
|
62
|
+
"skills/agent-architecture.md",
|
|
63
|
+
"skills/references/specify.md",
|
|
64
|
+
"skills/references/context-limits.md",
|
|
65
|
+
"skills/engineering-standards.md",
|
|
66
|
+
BASELINE_RULE,
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
tasksTurn: {
|
|
70
|
+
label: "Tasks turn (hub + tasks + task graph)",
|
|
71
|
+
paths: [
|
|
72
|
+
"skills/agent-architecture.md",
|
|
73
|
+
"skills/references/tasks.md",
|
|
74
|
+
"skills/task-graph-engineering.md",
|
|
75
|
+
"skills/references/context-limits.md",
|
|
76
|
+
BASELINE_RULE,
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
executeLoop: {
|
|
80
|
+
label: "Execute /loop (one wave — inline or parallel)",
|
|
81
|
+
paths: [
|
|
82
|
+
"skills/references/implement.md",
|
|
83
|
+
"skills/engineering-standards.md",
|
|
84
|
+
BASELINE_RULE,
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
verifyTurn: {
|
|
88
|
+
label: "Verify turn (independent reviewer)",
|
|
89
|
+
paths: [
|
|
90
|
+
"skills/references/validate.md",
|
|
91
|
+
"skills/security-review.md",
|
|
92
|
+
"skills/references/context-limits.md",
|
|
93
|
+
BASELINE_RULE,
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @returns {Promise<{
|
|
100
|
+
* profiles: Record<string, { label: string, chars: number, tokens: number, files: number }>,
|
|
101
|
+
* savings: { specifyVsNaivePct: number, executeVsNaivePct: number }
|
|
102
|
+
* }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function measureLoadProfiles() {
|
|
105
|
+
/** @type {Record<string, { label: string, chars: number, tokens: number, files: number }>} */
|
|
106
|
+
const profiles = {};
|
|
107
|
+
|
|
108
|
+
for (const [key, profile] of Object.entries(LOAD_PROFILES)) {
|
|
109
|
+
const measured = await measureBundle(profile.paths);
|
|
110
|
+
profiles[key] = { label: profile.label, ...measured };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const naive = profiles.naiveFullDump.tokens;
|
|
114
|
+
const specify = profiles.specifyTurn.tokens;
|
|
115
|
+
const execute = profiles.executeLoop.tokens;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
profiles,
|
|
119
|
+
savings: {
|
|
120
|
+
specifyVsNaivePct: naive > 0 ? Math.round((1 - specify / naive) * 100) : 0,
|
|
121
|
+
executeVsNaivePct: naive > 0 ? Math.round((1 - execute / naive) * 100) : 0,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|