@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. 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
+ }