@seanyao/roll 3.620.1 → 3.624.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/CHANGELOG.md +46 -0
- package/dist/roll.mjs +4563 -3369
- package/package.json +1 -1
- package/lib/__pycache__/changelog_audit.cpython-314.pyc +0 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/skills/README.md +0 -64
- package/skills/docs/skill-authoring.md +0 -74
- package/skills/reports/skill-audit-summary.md +0 -53
- package/skills/roll-.changelog/SKILL.md +0 -47
- package/skills/roll-.changelog/references/full-contract.md +0 -462
- package/skills/roll-.clarify/SKILL.md +0 -64
- package/skills/roll-.dream/SKILL.md +0 -47
- package/skills/roll-.dream/references/full-contract.md +0 -365
- package/skills/roll-.echo/SKILL.md +0 -118
- package/skills/roll-.qa/SKILL.md +0 -47
- package/skills/roll-.qa/references/full-contract.md +0 -256
- package/skills/roll-.review/SKILL.md +0 -148
- package/skills/roll-build/SKILL.md +0 -49
- package/skills/roll-build/references/full-contract.md +0 -968
- package/skills/roll-debug/SKILL.md +0 -48
- package/skills/roll-debug/assets/injectable-bb.js +0 -263
- package/skills/roll-debug/references/full-contract.md +0 -607
- package/skills/roll-design/SKILL.md +0 -52
- package/skills/roll-design/references/engineering-checklist.md +0 -298
- package/skills/roll-design/references/full-contract.md +0 -940
- package/skills/roll-doc-audit/SKILL.md +0 -51
- package/skills/roll-doc-audit/references/full-contract.md +0 -796
- package/skills/roll-doctor/SKILL.md +0 -211
- package/skills/roll-fix/SKILL.md +0 -49
- package/skills/roll-fix/references/full-contract.md +0 -672
- package/skills/roll-idea/SKILL.md +0 -62
- package/skills/roll-loop/SKILL.md +0 -50
- package/skills/roll-loop/references/full-contract.md +0 -534
- package/skills/roll-notes/SKILL.md +0 -107
- package/skills/roll-onboard/SKILL.md +0 -238
- package/skills/roll-peer/SKILL.md +0 -47
- package/skills/roll-peer/references/full-contract.md +0 -323
- package/skills/roll-propose/SKILL.md +0 -155
- package/skills/roll-review-pr/SKILL.md +0 -62
- package/skills/roll-spar/SKILL.md +0 -47
- package/skills/roll-spar/references/full-contract.md +0 -288
- package/skills/route-cases/skills.json +0 -216
- package/skills/scripts/audit-skills.mjs +0 -272
- package/skills/scripts/test-audit-skills.mjs +0 -39
- package/skills/tests/fixtures/skill-audit/block-skill/SKILL.md +0 -12
- package/skills/tests/fixtures/skill-audit/minimal-skill/SKILL.md +0 -8
- package/skills/tests/fixtures/skill-audit/quoted-skill/SKILL.md +0 -10
- package/skills/tests/fixtures/skill-audit/route-cases.json +0 -21
- package/skills/tests/fixtures/skill-audit/spoke-skill/SKILL.md +0 -12
- package/skills/tests/fixtures/skill-audit/spoke-skill/references/runbook.md +0 -3
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
function readText(file) {
|
|
8
|
-
return fs.readFileSync(file, "utf8");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function stripYamlQuotes(value) {
|
|
12
|
-
const trimmed = value.trim();
|
|
13
|
-
if (
|
|
14
|
-
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
15
|
-
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
16
|
-
) {
|
|
17
|
-
return trimmed.slice(1, -1);
|
|
18
|
-
}
|
|
19
|
-
return trimmed;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function parseFrontmatter(text) {
|
|
23
|
-
if (!text.startsWith("---\n")) {
|
|
24
|
-
return { fields: {}, body: text, ok: false };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const end = text.indexOf("\n---", 4);
|
|
28
|
-
if (end === -1) {
|
|
29
|
-
return { fields: {}, body: text, ok: false };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const raw = text.slice(4, end);
|
|
33
|
-
const bodyStart = text.indexOf("\n", end + 4);
|
|
34
|
-
const body = bodyStart === -1 ? "" : text.slice(bodyStart + 1);
|
|
35
|
-
const fields = {};
|
|
36
|
-
const lines = raw.split(/\r?\n/);
|
|
37
|
-
|
|
38
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
39
|
-
const line = lines[index];
|
|
40
|
-
const blockMatch = line.match(/^([A-Za-z0-9_.-]+):\s*\|\s*$/);
|
|
41
|
-
if (blockMatch) {
|
|
42
|
-
const key = blockMatch[1];
|
|
43
|
-
const blockLines = [];
|
|
44
|
-
index += 1;
|
|
45
|
-
while (index < lines.length) {
|
|
46
|
-
const next = lines[index];
|
|
47
|
-
if (/^\S[^:]*:/.test(next)) {
|
|
48
|
-
index -= 1;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
blockLines.push(next.replace(/^ ?/, ""));
|
|
52
|
-
index += 1;
|
|
53
|
-
}
|
|
54
|
-
fields[key] = blockLines.join("\n").trim();
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const scalarMatch = line.match(/^([A-Za-z0-9_.-]+):\s*(.*)$/);
|
|
59
|
-
if (scalarMatch) {
|
|
60
|
-
fields[scalarMatch[1]] = stripYamlQuotes(scalarMatch[2]);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return { fields, body, ok: true };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function wordCount(text) {
|
|
68
|
-
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
69
|
-
return words.length;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function walkFiles(dir) {
|
|
73
|
-
if (!fs.existsSync(dir)) return [];
|
|
74
|
-
return fs
|
|
75
|
-
.readdirSync(dir, { withFileTypes: true })
|
|
76
|
-
.flatMap((entry) => {
|
|
77
|
-
const full = path.join(dir, entry.name);
|
|
78
|
-
if (entry.isDirectory()) return walkFiles(full);
|
|
79
|
-
if (entry.isFile()) return [full];
|
|
80
|
-
return [];
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function toPosix(relativePath) {
|
|
85
|
-
return relativePath.split(path.sep).join("/");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function collectSpokeFiles(skillDir) {
|
|
89
|
-
return ["references", "assets", "scripts"].flatMap((dirName) => {
|
|
90
|
-
const base = path.join(skillDir, dirName);
|
|
91
|
-
return walkFiles(base).map((file) => toPosix(path.relative(skillDir, file)));
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function collectReferencedSpokes(body) {
|
|
96
|
-
const refs = new Set();
|
|
97
|
-
const patterns = [
|
|
98
|
-
/\]\(((?:references|assets|scripts)\/[^)#\s]+)(?:#[^)]+)?\)/g,
|
|
99
|
-
/`((?:references|assets|scripts)\/[^`#]+)(?:#[^`]*)?`/g,
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
for (const pattern of patterns) {
|
|
103
|
-
for (const match of body.matchAll(pattern)) {
|
|
104
|
-
refs.add(match[1].replace(/^\.\//, ""));
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return [...refs].sort();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function parseSkillFile(file) {
|
|
112
|
-
const text = readText(file);
|
|
113
|
-
const { fields, body, ok } = parseFrontmatter(text);
|
|
114
|
-
const skillDir = path.dirname(file);
|
|
115
|
-
const description = fields.description ?? "";
|
|
116
|
-
const spokeFiles = collectSpokeFiles(skillDir);
|
|
117
|
-
const referencedSpokes = collectReferencedSpokes(body);
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
name: fields.name ?? path.basename(skillDir),
|
|
121
|
-
file,
|
|
122
|
-
frontmatterOk: ok,
|
|
123
|
-
lines: text.trimEnd().split(/\r?\n/).length,
|
|
124
|
-
description,
|
|
125
|
-
descriptionWordCount: wordCount(description),
|
|
126
|
-
descriptionLoadTrigger: /^Load when\b/i.test(description),
|
|
127
|
-
hasWhenNotToUse: /^##\s+When Not to Use\b/im.test(body),
|
|
128
|
-
hasGotchas: /^##\s+(Gotchas|Known Failure Modes)\b/im.test(body),
|
|
129
|
-
hasReviewedWaiver: /Reviewed Waiver:/i.test(body),
|
|
130
|
-
auxiliaryDirs: ["scripts", "references", "assets"].filter((dirName) =>
|
|
131
|
-
fs.existsSync(path.join(skillDir, dirName)),
|
|
132
|
-
),
|
|
133
|
-
spokeFiles,
|
|
134
|
-
referencedSpokes,
|
|
135
|
-
missingSpokeRefs: referencedSpokes.filter((ref) => !spokeFiles.includes(ref)),
|
|
136
|
-
unreferencedSpokes: spokeFiles.filter((filePath) => !referencedSpokes.includes(filePath)),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function findSkillFiles(skillsDir) {
|
|
141
|
-
return fs
|
|
142
|
-
.readdirSync(skillsDir, { withFileTypes: true })
|
|
143
|
-
.filter((entry) => entry.isDirectory())
|
|
144
|
-
.map((entry) => path.join(skillsDir, entry.name, "SKILL.md"))
|
|
145
|
-
.filter((file) => fs.existsSync(file))
|
|
146
|
-
.sort();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function loadRouteCases(routeFile) {
|
|
150
|
-
if (!fs.existsSync(routeFile)) {
|
|
151
|
-
return { skills: {} };
|
|
152
|
-
}
|
|
153
|
-
return JSON.parse(readText(routeFile));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function routeCoverageFor(skillName, routes) {
|
|
157
|
-
const entry = routes.skills?.[skillName] ?? {};
|
|
158
|
-
const positive = Array.isArray(entry.positive) ? entry.positive : [];
|
|
159
|
-
const negative = Array.isArray(entry.negative) ? entry.negative : [];
|
|
160
|
-
return {
|
|
161
|
-
positive,
|
|
162
|
-
negative,
|
|
163
|
-
hasMinimumCoverage: positive.length >= 2 && negative.length >= 2,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function violationsFor(skill, routeCoverage) {
|
|
168
|
-
const violations = [];
|
|
169
|
-
|
|
170
|
-
if (!skill.frontmatterOk) violations.push("frontmatter-missing-or-invalid");
|
|
171
|
-
if (!skill.descriptionLoadTrigger) violations.push("description-not-load-trigger");
|
|
172
|
-
if (skill.descriptionWordCount > 50) violations.push("description-over-50-words");
|
|
173
|
-
if (!routeCoverage.hasMinimumCoverage) violations.push("route-fixture-coverage-missing");
|
|
174
|
-
if (!skill.hasGotchas) violations.push("gotchas-missing");
|
|
175
|
-
if (skill.lines > 250 && !skill.hasReviewedWaiver) violations.push("hub-over-250-lines");
|
|
176
|
-
for (const missing of skill.missingSpokeRefs) violations.push(`missing-spoke-ref:${missing}`);
|
|
177
|
-
for (const extra of skill.unreferencedSpokes) violations.push(`unreferenced-spoke:${extra}`);
|
|
178
|
-
|
|
179
|
-
return violations;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function auditSkills({ skillsDir, routeFile }) {
|
|
183
|
-
const routes = loadRouteCases(routeFile);
|
|
184
|
-
const skills = findSkillFiles(skillsDir).map((file) => {
|
|
185
|
-
const skill = parseSkillFile(file);
|
|
186
|
-
const routeCoverage = routeCoverageFor(skill.name, routes);
|
|
187
|
-
return {
|
|
188
|
-
...skill,
|
|
189
|
-
routeCoverage: {
|
|
190
|
-
positiveCount: routeCoverage.positive.length,
|
|
191
|
-
negativeCount: routeCoverage.negative.length,
|
|
192
|
-
hasMinimumCoverage: routeCoverage.hasMinimumCoverage,
|
|
193
|
-
},
|
|
194
|
-
violations: violationsFor(skill, routeCoverage),
|
|
195
|
-
};
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const summary = {
|
|
199
|
-
skills: skills.length,
|
|
200
|
-
violations: skills.reduce((count, skill) => count + skill.violations.length, 0),
|
|
201
|
-
over250: skills.filter((skill) => skill.lines > 250).length,
|
|
202
|
-
withGotchas: skills.filter((skill) => skill.hasGotchas).length,
|
|
203
|
-
loadTriggerDescriptions: skills.filter((skill) => skill.descriptionLoadTrigger).length,
|
|
204
|
-
withAuxiliaryFiles: skills.filter((skill) => skill.spokeFiles.length > 0).length,
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
return { summary, skills };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function printHuman(report) {
|
|
211
|
-
console.log(`Skill audit: ${report.summary.skills} skills`);
|
|
212
|
-
console.log(`Load-trigger descriptions: ${report.summary.loadTriggerDescriptions}/${report.summary.skills}`);
|
|
213
|
-
console.log(`Gotchas coverage: ${report.summary.withGotchas}/${report.summary.skills}`);
|
|
214
|
-
console.log(`Skills over 250 lines: ${report.summary.over250}`);
|
|
215
|
-
console.log(`Skills with auxiliary files: ${report.summary.withAuxiliaryFiles}`);
|
|
216
|
-
console.log(`Violations: ${report.summary.violations}`);
|
|
217
|
-
|
|
218
|
-
for (const skill of report.skills) {
|
|
219
|
-
const markers = [];
|
|
220
|
-
markers.push(`${skill.lines} lines`);
|
|
221
|
-
markers.push(`${skill.descriptionWordCount} desc words`);
|
|
222
|
-
markers.push(`${skill.routeCoverage.positiveCount}+/${skill.routeCoverage.negativeCount}- route cases`);
|
|
223
|
-
if (skill.spokeFiles.length > 0) markers.push(`${skill.spokeFiles.length} spokes`);
|
|
224
|
-
const status = skill.violations.length === 0 ? "ok" : skill.violations.join(", ");
|
|
225
|
-
console.log(`- ${skill.name}: ${status} (${markers.join("; ")})`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function parseArgs(argv) {
|
|
230
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
231
|
-
const options = {
|
|
232
|
-
skillsDir: root,
|
|
233
|
-
routeFile: path.join(root, "route-cases", "skills.json"),
|
|
234
|
-
json: false,
|
|
235
|
-
strict: false,
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
239
|
-
const arg = argv[index];
|
|
240
|
-
if (arg === "--json") options.json = true;
|
|
241
|
-
else if (arg === "--strict") options.strict = true;
|
|
242
|
-
else if (arg === "--skills-dir") {
|
|
243
|
-
index += 1;
|
|
244
|
-
options.skillsDir = path.resolve(argv[index]);
|
|
245
|
-
} else if (arg === "--routes") {
|
|
246
|
-
index += 1;
|
|
247
|
-
options.routeFile = path.resolve(argv[index]);
|
|
248
|
-
} else {
|
|
249
|
-
throw new Error(`Unknown argument: ${arg}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return options;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function main() {
|
|
257
|
-
const options = parseArgs(process.argv.slice(2));
|
|
258
|
-
const report = auditSkills(options);
|
|
259
|
-
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
260
|
-
else printHuman(report);
|
|
261
|
-
|
|
262
|
-
if (options.strict && report.summary.violations > 0) {
|
|
263
|
-
process.exitCode = 1;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
268
|
-
main().catch((error) => {
|
|
269
|
-
console.error(error.message);
|
|
270
|
-
process.exitCode = 1;
|
|
271
|
-
});
|
|
272
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { auditSkills, parseSkillFile } from "./audit-skills.mjs";
|
|
7
|
-
|
|
8
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
-
const fixtureRoot = path.join(root, "tests", "fixtures", "skill-audit");
|
|
10
|
-
|
|
11
|
-
const quoted = parseSkillFile(path.join(fixtureRoot, "quoted-skill", "SKILL.md"));
|
|
12
|
-
assert.equal(quoted.name, "quoted-skill");
|
|
13
|
-
assert.equal(quoted.description, "Load when quoted scalar YAML should parse cleanly.");
|
|
14
|
-
assert.equal(quoted.descriptionLoadTrigger, true);
|
|
15
|
-
|
|
16
|
-
const block = parseSkillFile(path.join(fixtureRoot, "block-skill", "SKILL.md"));
|
|
17
|
-
assert.equal(block.name, "block-skill");
|
|
18
|
-
assert.match(block.description, /Load when block descriptions/);
|
|
19
|
-
assert.equal(block.descriptionLoadTrigger, true);
|
|
20
|
-
|
|
21
|
-
const minimal = parseSkillFile(path.join(fixtureRoot, "minimal-skill", "SKILL.md"));
|
|
22
|
-
assert.equal(minimal.hasGotchas, false);
|
|
23
|
-
assert.equal(minimal.hasWhenNotToUse, false);
|
|
24
|
-
|
|
25
|
-
const spoke = parseSkillFile(path.join(fixtureRoot, "spoke-skill", "SKILL.md"));
|
|
26
|
-
assert.deepEqual(spoke.spokeFiles, ["references/runbook.md"]);
|
|
27
|
-
assert.deepEqual(spoke.referencedSpokes, ["references/runbook.md"]);
|
|
28
|
-
assert.deepEqual(spoke.missingSpokeRefs, []);
|
|
29
|
-
assert.deepEqual(spoke.unreferencedSpokes, []);
|
|
30
|
-
|
|
31
|
-
const report = auditSkills({
|
|
32
|
-
skillsDir: fixtureRoot,
|
|
33
|
-
routeFile: path.join(fixtureRoot, "route-cases.json"),
|
|
34
|
-
});
|
|
35
|
-
assert.equal(report.summary.skills, 4);
|
|
36
|
-
assert.equal(report.skills.find((skill) => skill.name === "minimal-skill").violations.includes("gotchas-missing"), true);
|
|
37
|
-
assert.equal(report.skills.find((skill) => skill.name === "spoke-skill").violations.length, 0);
|
|
38
|
-
|
|
39
|
-
console.log("audit-skills tests passed");
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"skills": {
|
|
4
|
-
"quoted-skill": {
|
|
5
|
-
"positive": ["parse a quoted description", "audit scalar YAML frontmatter"],
|
|
6
|
-
"negative": ["parse block YAML only", "ignore all skill files"]
|
|
7
|
-
},
|
|
8
|
-
"block-skill": {
|
|
9
|
-
"positive": ["parse a block description", "audit multiline YAML frontmatter"],
|
|
10
|
-
"negative": ["parse quoted YAML only", "skip description checks"]
|
|
11
|
-
},
|
|
12
|
-
"minimal-skill": {
|
|
13
|
-
"positive": ["confirm missing optional sections do not crash", "audit a tiny skill"],
|
|
14
|
-
"negative": ["validate spoke references", "require gotchas to parse frontmatter"]
|
|
15
|
-
},
|
|
16
|
-
"spoke-skill": {
|
|
17
|
-
"positive": ["validate spoke references", "find auxiliary runbooks"],
|
|
18
|
-
"negative": ["audit only frontmatter", "ignore references directories"]
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spoke-skill
|
|
3
|
-
description: "Load when auxiliary files and references need integrity checks."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Spoke Skill
|
|
7
|
-
|
|
8
|
-
Read [the runbook](references/runbook.md) when executing the full workflow.
|
|
9
|
-
|
|
10
|
-
## Gotchas
|
|
11
|
-
|
|
12
|
-
- Every spoke file must be referenced by the hub.
|