specsail 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/ROADMAP.md +19 -0
- package/bin/specsail.js +4 -0
- package/package.json +51 -4
- package/scripts/check-content.mjs +62 -0
- package/scripts/check-protected-paths.sh +20 -0
- package/scripts/new-feature.sh +25 -0
- package/scripts/open-pr.sh +15 -0
- package/scripts/run-agent.sh +44 -0
- package/scripts/start-phase.sh +9 -0
- package/skills/acceptance-tests/SKILL.md +35 -0
- package/skills/implement/SKILL.md +36 -0
- package/skills/plan/SKILL.md +38 -0
- package/skills/review/SKILL.md +32 -0
- package/skills/ship/SKILL.md +27 -0
- package/skills/understand/SKILL.md +32 -0
- package/specs/_templates/blocked.md +15 -0
- package/specs/_templates/plan.md +23 -0
- package/specs/_templates/request.md +19 -0
- package/specs/_templates/review.md +18 -0
- package/specs/_templates/spec.md +20 -0
- package/specs/_templates/tasks.md +7 -0
- package/src/cli.js +297 -0
- package/templates/.github/CODEOWNERS +6 -0
- package/templates/.github/workflows/verify.yml +18 -0
- package/templates/.specsail/AGENTS.md +30 -0
- package/templates/.specsail/agents.env.example +15 -0
- package/templates/.specsail/docs/runbook.md +30 -0
- package/templates/.specsail/docs/workflow.md +47 -0
- package/templates/Makefile +19 -0
- package/templates/docs/architecture/.gitkeep +0 -0
- package/templates/docs/architecture.md +15 -0
- package/templates/docs/project.md +19 -0
- package/test/acceptance/cli.acceptance.test.js +111 -0
- package/test/cli.test.js +24 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Review: {{SLUG}} / T-?
|
|
2
|
+
Iteration: 1
|
|
3
|
+
Verdict: CHANGES_REQUESTED
|
|
4
|
+
<!-- Written only when changes are needed. If all is good, mark DONE in tasks.md and don't write this file. -->
|
|
5
|
+
|
|
6
|
+
## AC coverage
|
|
7
|
+
| AC | Test | Result |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| AC-1 | tests/acceptance/... | pass / missing / weak |
|
|
10
|
+
|
|
11
|
+
## Findings
|
|
12
|
+
<!-- Every finding needs evidence. Severity: BLOCKER (spec/AC violated, bug, broken invariant) | MAJOR | NIT (not blocking) -->
|
|
13
|
+
1. **[BLOCKER]** `path/file.ext:42` — what is wrong, which AC/invariant it violates, expected fix direction.
|
|
14
|
+
|
|
15
|
+
## Checked and OK
|
|
16
|
+
- Scope creep: none
|
|
17
|
+
- Protected paths untouched
|
|
18
|
+
- Docs updated: yes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Spec: {{SLUG}}
|
|
2
|
+
Status: DRAFT
|
|
3
|
+
<!-- A human changes DRAFT to APPROVED. Agents never do. -->
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
## Objective
|
|
8
|
+
<!-- One or two sentences: what outcome, for whom. -->
|
|
9
|
+
|
|
10
|
+
## Acceptance criteria
|
|
11
|
+
<!-- IDs are stable. Tests are named by ID. Use domain glossary terms exactly. -->
|
|
12
|
+
|
|
13
|
+
### AC-1: <short title>
|
|
14
|
+
- **Given**
|
|
15
|
+
- **When**
|
|
16
|
+
- **Then**
|
|
17
|
+
|
|
18
|
+
## Out of scope
|
|
19
|
+
|
|
20
|
+
## Open questions
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Tasks: {{SLUG}}
|
|
2
|
+
States: TODO → IN_PROGRESS → DEV_DONE → DONE | CHANGES_REQUESTED | BLOCKED
|
|
3
|
+
Only the role named in docs/workflow.md changes a status. Plan.md defines tasks; this file only tracks status.
|
|
4
|
+
|
|
5
|
+
| Task | Status | Review loops | Notes |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| T-1 | TODO | 0 | |
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { access, copyFile, lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const stageRoles = ["understand", "plan", "acceptance-tests", "implement", "review", "ship"];
|
|
8
|
+
const typeNames = {
|
|
9
|
+
fix: "FIX",
|
|
10
|
+
update: "FEATURE UPDATE",
|
|
11
|
+
new: "NEW FEATURE",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const help = `SpecSail — navigate by spec, deliver with confidence.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
specsail init Add SpecSail to the current project
|
|
18
|
+
specsail new <slug> <fix|update|new>
|
|
19
|
+
Create a feature's request/spec/plan/tasks
|
|
20
|
+
specsail doctor Check the current project's SpecSail setup
|
|
21
|
+
specsail --version Print the installed version
|
|
22
|
+
specsail --help Show this help
|
|
23
|
+
|
|
24
|
+
The Captain is always human. SpecSail does not approve specs/tests or merge PRs.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function writeLine(stream, text) {
|
|
28
|
+
stream(`${text}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function exists(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
await access(filePath, constants.F_OK);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function lstatIfExists(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
return await lstat(filePath);
|
|
43
|
+
} catch (failure) {
|
|
44
|
+
if (failure.code === "ENOENT") return undefined;
|
|
45
|
+
throw failure;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function ensureSafeDirectory(projectRoot, directory) {
|
|
50
|
+
const relative = path.relative(projectRoot, directory);
|
|
51
|
+
if (relative && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) {
|
|
52
|
+
throw new Error(`Refusing to write outside the project: ${directory}`);
|
|
53
|
+
}
|
|
54
|
+
let current = projectRoot;
|
|
55
|
+
for (const segment of relative ? relative.split(path.sep) : []) {
|
|
56
|
+
current = path.join(current, segment);
|
|
57
|
+
try {
|
|
58
|
+
const details = await lstat(current);
|
|
59
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
60
|
+
throw new Error(`Refusing to write through a symlink or non-directory path: ${current}`);
|
|
61
|
+
}
|
|
62
|
+
} catch (failure) {
|
|
63
|
+
if (failure.code !== "ENOENT") throw failure;
|
|
64
|
+
await mkdir(current);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function copyTree(source, destination, { suffixes, projectRoot } = {}) {
|
|
70
|
+
let created = 0;
|
|
71
|
+
let skipped = 0;
|
|
72
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const sourcePath = path.join(source, entry.name);
|
|
75
|
+
const destinationPath = path.join(destination, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
await ensureSafeDirectory(projectRoot, destinationPath);
|
|
78
|
+
const result = await copyTree(sourcePath, destinationPath, { suffixes, projectRoot });
|
|
79
|
+
created += result.created;
|
|
80
|
+
skipped += result.skipped;
|
|
81
|
+
} else if (entry.isFile() && (!suffixes || suffixes.some((suffix) => entry.name.endsWith(suffix)))) {
|
|
82
|
+
await ensureSafeDirectory(projectRoot, path.dirname(destinationPath));
|
|
83
|
+
try {
|
|
84
|
+
const sourceStat = await stat(sourcePath);
|
|
85
|
+
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL);
|
|
86
|
+
await access(destinationPath, constants.F_OK);
|
|
87
|
+
if (process.platform !== "win32") await import("node:fs/promises").then(({ chmod }) => chmod(destinationPath, sourceStat.mode));
|
|
88
|
+
created += 1;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error.code === "EEXIST") skipped += 1;
|
|
91
|
+
else throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { created, skipped };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function copyFileIfMissing(source, destination, projectRoot) {
|
|
99
|
+
await ensureSafeDirectory(projectRoot, path.dirname(destination));
|
|
100
|
+
try {
|
|
101
|
+
const sourceStat = await stat(source);
|
|
102
|
+
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
|
103
|
+
if (process.platform !== "win32") await import("node:fs/promises").then(({ chmod }) => chmod(destination, sourceStat.mode));
|
|
104
|
+
return true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error.code === "EEXIST") return false;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function updateManagedBlock(filePath, marker, text, projectRoot) {
|
|
112
|
+
await ensureSafeDirectory(projectRoot, path.dirname(filePath));
|
|
113
|
+
let current = "";
|
|
114
|
+
const details = await lstatIfExists(filePath);
|
|
115
|
+
if (details) {
|
|
116
|
+
if (details.isSymbolicLink() || !details.isFile()) throw new Error(`Refusing to modify symlink or non-file: ${filePath}`);
|
|
117
|
+
current = await readFile(filePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
const [start, end] = marker;
|
|
120
|
+
const startCount = current.split(start).length - 1;
|
|
121
|
+
const endCount = current.split(end).length - 1;
|
|
122
|
+
if (startCount !== endCount || startCount > 1) {
|
|
123
|
+
throw new Error(`Cannot safely update managed section in ${path.relative(process.cwd(), filePath)}: markers are unbalanced or duplicated.`);
|
|
124
|
+
}
|
|
125
|
+
const section = `${start}\n${text.trim()}\n${end}`;
|
|
126
|
+
if (startCount === 1) {
|
|
127
|
+
const escapedStart = start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
128
|
+
const escapedEnd = end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
current = current.replace(new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`), section);
|
|
130
|
+
} else {
|
|
131
|
+
current = `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${section}\n`;
|
|
132
|
+
}
|
|
133
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
134
|
+
await writeFile(filePath, current, "utf8");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function loadInstalledConfig(configPath) {
|
|
138
|
+
try {
|
|
139
|
+
const details = await lstatIfExists(configPath);
|
|
140
|
+
if (!details) return {};
|
|
141
|
+
if (details.isSymbolicLink() || !details.isFile()) throw new Error("expected a regular file, not a symlink");
|
|
142
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
143
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected a JSON object");
|
|
144
|
+
return parsed;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new Error(`Cannot read .specsail.json: ${error.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function init(cwd, output, root) {
|
|
151
|
+
const configPath = path.join(cwd, ".specsail.json");
|
|
152
|
+
const config = await loadInstalledConfig(configPath);
|
|
153
|
+
const operations = [
|
|
154
|
+
[path.join(root, "templates/.specsail"), path.join(cwd, ".specsail")],
|
|
155
|
+
[path.join(root, "skills"), path.join(cwd, ".specsail/skills")],
|
|
156
|
+
[path.join(root, "scripts"), path.join(cwd, ".specsail/scripts"), { suffixes: [".sh"] }],
|
|
157
|
+
[path.join(root, "specs/_templates"), path.join(cwd, ".specsail/templates/specs/_templates")],
|
|
158
|
+
];
|
|
159
|
+
let created = 0;
|
|
160
|
+
let skipped = 0;
|
|
161
|
+
for (const [source, destination, options] of operations) {
|
|
162
|
+
const result = await copyTree(source, destination, { ...options, projectRoot: cwd });
|
|
163
|
+
created += result.created;
|
|
164
|
+
skipped += result.skipped;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const individualFiles = [
|
|
168
|
+
[path.join(root, "templates/Makefile"), path.join(cwd, "Makefile")],
|
|
169
|
+
[path.join(root, "templates/docs/project.md"), path.join(cwd, "docs/project.md")],
|
|
170
|
+
[path.join(root, "templates/docs/architecture.md"), path.join(cwd, "docs/architecture.md")],
|
|
171
|
+
[path.join(root, "templates/docs/architecture/.gitkeep"), path.join(cwd, "docs/architecture/.gitkeep")],
|
|
172
|
+
[path.join(root, "templates/.github/CODEOWNERS"), path.join(cwd, ".github/CODEOWNERS")],
|
|
173
|
+
[path.join(root, "templates/.github/workflows/verify.yml"), path.join(cwd, ".github/workflows/verify.yml")],
|
|
174
|
+
];
|
|
175
|
+
for (const [source, destination] of individualFiles) {
|
|
176
|
+
if (await copyFileIfMissing(source, destination, cwd)) created += 1;
|
|
177
|
+
else skipped += 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await updateManagedBlock(
|
|
181
|
+
path.join(cwd, "AGENTS.md"),
|
|
182
|
+
["<!-- specsail:start -->", "<!-- specsail:end -->"],
|
|
183
|
+
"## SpecSail\n\nThis project uses SpecSail, a Captain-led, spec-driven development workflow. Read .specsail/AGENTS.md for project workflow rules. For the assigned stage, read the matching skill in .specsail/skills/<stage>/SKILL.md. The human Captain approves specs and acceptance tests and merges pull requests; agents must not take those gates.",
|
|
184
|
+
cwd,
|
|
185
|
+
);
|
|
186
|
+
await updateManagedBlock(
|
|
187
|
+
path.join(cwd, ".gitignore"),
|
|
188
|
+
["# >>> SpecSail >>>", "# <<< SpecSail <<<"],
|
|
189
|
+
".specsail/agents.env",
|
|
190
|
+
cwd,
|
|
191
|
+
);
|
|
192
|
+
config.version = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")).version;
|
|
193
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
194
|
+
writeLine(output, `SpecSail initialized (${config.version}). Added ${created} file(s); left ${skipped} existing file(s) unchanged.`);
|
|
195
|
+
writeLine(output, "Next: fill docs/project.md and docs/architecture.md, configure Makefile, then run specsail doctor.");
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validSlug(slug) {
|
|
200
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function newFeature(cwd, args, output) {
|
|
204
|
+
const [slug, kind, ...extra] = args;
|
|
205
|
+
if (!slug || !kind || extra.length || !validSlug(slug) || !Object.hasOwn(typeNames, kind)) {
|
|
206
|
+
throw new Error("Usage: specsail new <lowercase-kebab-slug> <fix|update|new>");
|
|
207
|
+
}
|
|
208
|
+
const specsailRoot = path.join(cwd, ".specsail");
|
|
209
|
+
const templates = path.join(specsailRoot, "templates/specs/_templates");
|
|
210
|
+
if (!(await exists(templates))) throw new Error("SpecSail is not initialized here. Run `specsail init` first.");
|
|
211
|
+
const destination = path.join(cwd, "specs", slug);
|
|
212
|
+
if (await exists(destination)) throw new Error(`specs/${slug} already exists; refusing to overwrite it.`);
|
|
213
|
+
const entries = ["request.md", "spec.md", "plan.md", "tasks.md"];
|
|
214
|
+
await ensureSafeDirectory(cwd, path.join(cwd, "specs"));
|
|
215
|
+
await mkdir(destination, { recursive: true });
|
|
216
|
+
try {
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const source = await readFile(path.join(templates, entry), "utf8");
|
|
219
|
+
const content = source.replaceAll("{{SLUG}}", slug).replaceAll("{{TYPE}}", typeNames[kind]);
|
|
220
|
+
await writeFile(path.join(destination, entry), content, { flag: "wx" });
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new Error(`Could not scaffold specs/${slug}: ${error.message}. Remove the partial directory before retrying.`);
|
|
224
|
+
}
|
|
225
|
+
writeLine(output, `Created specs/${slug}/. Next: run ".specsail/scripts/run-agent.sh understand ${slug}" with your agent, or read the Scout skill.`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function hasGitRepository(cwd) {
|
|
230
|
+
let current = cwd;
|
|
231
|
+
while (true) {
|
|
232
|
+
if (await exists(path.join(current, ".git"))) return true;
|
|
233
|
+
const parent = path.dirname(current);
|
|
234
|
+
if (parent === current) return false;
|
|
235
|
+
current = parent;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function doctor(cwd, output, error) {
|
|
240
|
+
const failures = [];
|
|
241
|
+
const checks = [
|
|
242
|
+
[".specsail.json", path.join(cwd, ".specsail.json")],
|
|
243
|
+
[".specsail/AGENTS.md", path.join(cwd, ".specsail/AGENTS.md")],
|
|
244
|
+
["AGENTS.md SpecSail section", path.join(cwd, "AGENTS.md")],
|
|
245
|
+
];
|
|
246
|
+
for (const [label, filePath] of checks) {
|
|
247
|
+
if (!(await exists(filePath))) failures.push(`Missing ${label}; run specsail init.`);
|
|
248
|
+
}
|
|
249
|
+
for (const stage of stageRoles) {
|
|
250
|
+
const skill = path.join(cwd, ".specsail/skills", stage, "SKILL.md");
|
|
251
|
+
if (!(await exists(skill))) failures.push(`Missing ${path.relative(cwd, skill)}; run specsail init.`);
|
|
252
|
+
}
|
|
253
|
+
const makefile = path.join(cwd, "Makefile");
|
|
254
|
+
const makefileContent = await exists(makefile) ? await readFile(makefile, "utf8") : "";
|
|
255
|
+
if (!/^verify\s*:/m.test(makefileContent)) {
|
|
256
|
+
failures.push("Makefile must define a verify target. Configure it for this project's stack.");
|
|
257
|
+
} else if (makefileContent.includes("SPECSAIL_UNCONFIGURED_TEMPLATE")) {
|
|
258
|
+
failures.push("Makefile still contains the SpecSail starter template. Configure setup, lint, typecheck, test, and accept before relying on make verify.");
|
|
259
|
+
}
|
|
260
|
+
const agents = await exists(path.join(cwd, "AGENTS.md")) ? await readFile(path.join(cwd, "AGENTS.md"), "utf8") : "";
|
|
261
|
+
if (!agents.includes("<!-- specsail:start -->") || !agents.includes("<!-- specsail:end -->")) {
|
|
262
|
+
failures.push("AGENTS.md is missing the SpecSail managed section; run specsail init.");
|
|
263
|
+
}
|
|
264
|
+
if (!(await hasGitRepository(cwd))) writeLine(error, "Warning: no .git directory found. Initialize version control before starting a feature.");
|
|
265
|
+
if (failures.length) {
|
|
266
|
+
for (const failure of failures) writeLine(error, `✗ ${failure}`);
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
writeLine(output, "✓ SpecSail files and verify target are present.");
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function runCli(args, options = {}) {
|
|
274
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
275
|
+
const root = path.resolve(options.packageRoot ?? packageRoot);
|
|
276
|
+
const output = options.stdout ?? ((text) => process.stdout.write(text));
|
|
277
|
+
const error = options.stderr ?? ((text) => process.stderr.write(text));
|
|
278
|
+
const [command, ...rest] = args;
|
|
279
|
+
try {
|
|
280
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
281
|
+
writeLine(output, help);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
285
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
286
|
+
writeLine(output, manifest.version);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
if (command === "init") return await init(cwd, output, root);
|
|
290
|
+
if (command === "new") return await newFeature(cwd, rest, output);
|
|
291
|
+
if (command === "doctor") return await doctor(cwd, output, error);
|
|
292
|
+
throw new Error(`Unknown command: ${command}. Run specsail --help for usage.`);
|
|
293
|
+
} catch (failure) {
|
|
294
|
+
writeLine(error, `Error: ${failure.message}`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: verify
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
jobs:
|
|
5
|
+
verify:
|
|
6
|
+
runs-on: ubuntu-24.04
|
|
7
|
+
steps:
|
|
8
|
+
- uses: actions/checkout@v7
|
|
9
|
+
with:
|
|
10
|
+
fetch-depth: 0
|
|
11
|
+
- name: Set up Node.js (for SpecSail CLI)
|
|
12
|
+
uses: actions/setup-node@v7
|
|
13
|
+
with:
|
|
14
|
+
node-version: 20
|
|
15
|
+
# Add your stack's setup action here if make setup needs it.
|
|
16
|
+
- run: make setup
|
|
17
|
+
- run: make verify
|
|
18
|
+
- run: make check-protected BASE=origin/${{ github.base_ref }}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SpecSail project rules
|
|
2
|
+
|
|
3
|
+
This project follows the SpecSail workflow. The human is the **Captain** and is the only person who approves specs, approves acceptance tests, or merges pull requests. Agents must stop at those gates and ask the Captain to act.
|
|
4
|
+
|
|
5
|
+
## Crew and skills
|
|
6
|
+
|
|
7
|
+
| Stage | Development role | Crew name | Skill |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Understand | Product Owner | Scout | `.specsail/skills/understand/SKILL.md` |
|
|
10
|
+
| Plan | Tech Lead | Navigator | `.specsail/skills/plan/SKILL.md` |
|
|
11
|
+
| Acceptance tests | QA | Quartermaster | `.specsail/skills/acceptance-tests/SKILL.md` |
|
|
12
|
+
| Implement | Developer | Hands | `.specsail/skills/implement/SKILL.md` |
|
|
13
|
+
| Review | Reviewer | First Mate | `.specsail/skills/review/SKILL.md` |
|
|
14
|
+
| Ship | Release | Bosun | `.specsail/skills/ship/SKILL.md` |
|
|
15
|
+
|
|
16
|
+
Use the stage name in files and commands. The crew name is a human-friendly alias. If an agent tool cannot load skills automatically, tell it to read this file and the relevant `SKILL.md` directly.
|
|
17
|
+
|
|
18
|
+
## Workflow
|
|
19
|
+
|
|
20
|
+
`request → approved spec → plan → approved acceptance tests (per phase) → implement one task → review → ship`
|
|
21
|
+
|
|
22
|
+
- Read `docs/project.md` and `docs/architecture.md` before scoping project work. Add module documentation under `docs/architecture/` when needed.
|
|
23
|
+
- Feature artifacts live in `specs/<feature>/`; templates are in `.specsail/templates/specs/_templates/`.
|
|
24
|
+
- Protected files are approved `spec.md`, `plan.md`, and `tests/acceptance/**`. Only the owning stage may edit them; commits use `spec:`, `plan:`, or `accept:` prefixes.
|
|
25
|
+
- If the request/spec is ambiguous or an acceptance criterion is unworkable, write `specs/<feature>/blocked.md` and stop. Never silently reinterpret an approved spec or weaken a test.
|
|
26
|
+
- Implement one planned task at a time. Do not broaden scope. Run `make verify` before marking a task `DEV_DONE`.
|
|
27
|
+
- Review must be independent of implementation and report evidence; it does not fix code. The Captain merges.
|
|
28
|
+
- Update docs in the same PR as the code change.
|
|
29
|
+
|
|
30
|
+
See `.specsail/docs/workflow.md` for stage definitions and `.specsail/docs/runbook.md` for operating steps.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copy to .specsail/agents.env (gitignored). Read by .specsail/scripts/run-agent.sh.
|
|
2
|
+
# CLI options: claude | codex | opencode | custom
|
|
3
|
+
AGENT_CLI=claude
|
|
4
|
+
# AGENT_CUSTOM_CMD="pi -p" # used when a role's CLI is "custom"; prompt is appended as last arg
|
|
5
|
+
|
|
6
|
+
# Per-stage overrides. You may invoke a stage by either its name or crew alias.
|
|
7
|
+
# Tier guidance: frontier for understand/plan/acceptance-tests/review, mid/cheap for implement.
|
|
8
|
+
# Use a DIFFERENT model family for review.
|
|
9
|
+
AGENT_MODEL_UNDERSTAND=
|
|
10
|
+
AGENT_MODEL_PLAN=
|
|
11
|
+
AGENT_MODEL_ACCEPTANCE_TESTS=
|
|
12
|
+
AGENT_MODEL_IMPLEMENT=
|
|
13
|
+
AGENT_MODEL_REVIEW=
|
|
14
|
+
AGENT_CLI_REVIEW=codex
|
|
15
|
+
AGENT_MODEL_SHIP=
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SpecSail runbook
|
|
2
|
+
|
|
3
|
+
Step-by-step operating guide. `.specsail/docs/workflow.md` is the concise reference.
|
|
4
|
+
|
|
5
|
+
## Start a feature
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
./.specsail/scripts/start-phase.sh <slug> 1
|
|
9
|
+
./.specsail/scripts/new-feature.sh <slug> <fix|update|new>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
If you prefer not to create a branch yet, run only `new-feature.sh`. Trivial changes can use a normal PR and `make verify` without feature artifacts.
|
|
13
|
+
|
|
14
|
+
## Run the crew
|
|
15
|
+
|
|
16
|
+
1. **Scout / Product Owner:** `./.specsail/scripts/run-agent.sh understand <slug>`. Give it the raw request, answer its questions, and confirm the routing. Exit when the request is complete and open questions are resolved or explicitly deferred.
|
|
17
|
+
2. **Navigator / Tech Lead, spec mode:** run `./.specsail/scripts/run-agent.sh plan <slug>`. The Captain reviews every acceptance criterion and sets `Status: APPROVED` in `spec.md`.
|
|
18
|
+
3. **Navigator / Tech Lead, plan mode:** run the same command again after approval. Check every criterion maps to tasks and each task has a verification command. The Captain reviews architecture decisions when architecture is affected.
|
|
19
|
+
4. **Quartermaster / QA:** `./.specsail/scripts/run-agent.sh acceptance-tests <slug> "AC-1,AC-2"`. The Captain checks tests fail for the intended missing behavior and approves them before implementation.
|
|
20
|
+
5. **Hands / Developer:** `./.specsail/scripts/run-agent.sh implement <slug> T-1`. One task per run. Check scope and protected paths.
|
|
21
|
+
6. **First Mate / Reviewer:** `./.specsail/scripts/run-agent.sh review <slug> T-1`. Use a different model family than the implementer. Rework findings up to three times, then escalate.
|
|
22
|
+
7. **Bosun / Release:** `./.specsail/scripts/run-agent.sh ship <slug>`, then `./.specsail/scripts/open-pr.sh <slug> 1`. The Captain checks CI and merges.
|
|
23
|
+
|
|
24
|
+
For later phases, create the phase branch with `start-phase.sh <slug> <phase>`, then write and approve that phase's acceptance tests before implementation.
|
|
25
|
+
|
|
26
|
+
## Optional headless runner
|
|
27
|
+
|
|
28
|
+
The Bash runner supports configured Claude, Codex, and OpenCode CLIs, plus a custom command. Copy `.specsail/agents.env.example` to `.specsail/agents.env` to configure it. The runner is optional; any coding tool that can read the project instructions and Markdown skills can follow the workflow interactively. Keep each agent's tool permissions and writable paths minimal.
|
|
29
|
+
|
|
30
|
+
The scripts require Git and Bash (macOS, Linux, WSL, or Git Bash). The Markdown workflow and Node-based `specsail` commands do not depend on a particular application language or coding agent.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SpecSail workflow reference
|
|
2
|
+
|
|
3
|
+
SpecSail uses durable Markdown artifacts to carry intent between stages. The Captain is the human who owns the approval and merge gates.
|
|
4
|
+
|
|
5
|
+
## Stages and crew
|
|
6
|
+
|
|
7
|
+
| # | Stage / skill | Development role — crew alias | Reads | Writes |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| 1 | understand | Product Owner — Scout | project, architecture, request | `request.md` |
|
|
10
|
+
| 2a | plan (spec mode) | Tech Lead — Navigator | project, architecture, request | `spec.md` |
|
|
11
|
+
| 2b | plan (plan mode) | Tech Lead — Navigator | approved spec, module docs | `plan.md`, `tasks.md` |
|
|
12
|
+
| 3 | acceptance-tests | QA — Quartermaster | approved spec, glossary, public interfaces | `tests/acceptance/<feature>/**` |
|
|
13
|
+
| 4 | implement | Developer — Hands | spec, plan, tasks, review, module docs | code, unit tests, task status |
|
|
14
|
+
| 5 | review | Reviewer — First Mate | spec, plan, tasks, diff, invariants | review report, task status |
|
|
15
|
+
| 6 | ship | Release — Bosun | spec, plan, tasks, diff | docs, changelog, PR |
|
|
16
|
+
|
|
17
|
+
The human Captain is not an agent role. Agents may prepare work for a gate, but only the Captain approves `spec.md`, approves each phase's acceptance tests, and merges.
|
|
18
|
+
|
|
19
|
+
## Change paths
|
|
20
|
+
|
|
21
|
+
| Type | Path |
|
|
22
|
+
|---|---|
|
|
23
|
+
| FIX | understand → spec with a reproduction criterion → acceptance test → implement → review → ship. Skip architecture planning unless needed. |
|
|
24
|
+
| FEATURE UPDATE | full flow; plan skips architecture decisions if `Architecture affected: NO`. |
|
|
25
|
+
| NEW FEATURE | full flow. |
|
|
26
|
+
| Trivial (typo, config, dependency bump) | Normal PR and `make verify`; no feature artifacts. |
|
|
27
|
+
|
|
28
|
+
## Human gates
|
|
29
|
+
|
|
30
|
+
1. Confirm the request and resolve or explicitly defer open questions.
|
|
31
|
+
2. Review and approve `spec.md` before planning by setting `Status: APPROVED`.
|
|
32
|
+
3. Review and approve acceptance tests for the current phase before implementation.
|
|
33
|
+
4. Merge only after CI is green, review is done, protected paths are clean, and docs are current.
|
|
34
|
+
|
|
35
|
+
Architecture decisions require Captain approval when the plan says architecture is affected. Merge is always a human decision.
|
|
36
|
+
|
|
37
|
+
## Task states and done
|
|
38
|
+
|
|
39
|
+
`TODO → IN_PROGRESS → DEV_DONE → DONE`; rework uses `CHANGES_REQUESTED`; an unresolved issue uses `BLOCKED` plus `blocked.md`. After three review iterations, stop and escalate to the Captain.
|
|
40
|
+
|
|
41
|
+
A task is done when `make verify` is green, linked acceptance criteria pass, required docs are updated, and the First Mate marks it `DONE`.
|
|
42
|
+
|
|
43
|
+
## Parallel work and review
|
|
44
|
+
|
|
45
|
+
- Define API contracts before parallel multi-app work; independent tasks may use separate worktrees.
|
|
46
|
+
- Do not parallelize tasks that touch the same module or shared task-status rows.
|
|
47
|
+
- CI is deterministic verification; First Mate review checks behavior and invariants; the Captain merges.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPECSAIL_UNCONFIGURED_TEMPLATE: replace these recipes with your project's real checks.
|
|
2
|
+
.PHONY: setup lint typecheck test accept verify check-protected
|
|
3
|
+
|
|
4
|
+
# Configure these commands for your project before using the workflow.
|
|
5
|
+
setup:
|
|
6
|
+
@echo "Configure setup for this project's stack"; exit 1
|
|
7
|
+
lint:
|
|
8
|
+
@echo "Configure lint for this project's stack"; exit 1
|
|
9
|
+
typecheck:
|
|
10
|
+
@echo "Configure typecheck for this project's stack (or use a no-op if not applicable)"; exit 1
|
|
11
|
+
test:
|
|
12
|
+
@echo "Configure unit/integration tests; exclude tests/acceptance/**"; exit 1
|
|
13
|
+
accept:
|
|
14
|
+
@echo "Configure acceptance tests from tests/acceptance/**"; exit 1
|
|
15
|
+
|
|
16
|
+
verify: lint typecheck test accept
|
|
17
|
+
|
|
18
|
+
check-protected:
|
|
19
|
+
@.specsail/scripts/check-protected-paths.sh $${BASE:-origin/main}
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
<!-- Complete for the consuming project. Keep the overview short; use docs/architecture/<module>.md for module detail. -->
|
|
4
|
+
|
|
5
|
+
## System context
|
|
6
|
+
|
|
7
|
+
## Module boundaries and allowed dependencies
|
|
8
|
+
|
|
9
|
+
## Critical data flows
|
|
10
|
+
|
|
11
|
+
## Cross-cutting concerns
|
|
12
|
+
|
|
13
|
+
## Invariants
|
|
14
|
+
|
|
15
|
+
## Key decisions
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Project
|
|
2
|
+
|
|
3
|
+
<!-- Complete this file for the consuming project. Keep details that agents cannot reliably infer from the code. -->
|
|
4
|
+
|
|
5
|
+
## What it is / who uses it
|
|
6
|
+
|
|
7
|
+
## Non-goals
|
|
8
|
+
|
|
9
|
+
## Glossary
|
|
10
|
+
| Term | Meaning |
|
|
11
|
+
|---|---|
|
|
12
|
+
|
|
13
|
+
## Tech stack
|
|
14
|
+
|
|
15
|
+
## Repo map
|
|
16
|
+
|
|
17
|
+
## Invariants and gotchas
|
|
18
|
+
|
|
19
|
+
## Pointers
|