specsail 0.0.0-stage → 0.2.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/ROADMAP.md +18 -0
- package/bin/specsail.js +4 -0
- package/package.json +51 -4
- package/scripts/check-content.mjs +80 -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/release.sh +29 -0
- package/scripts/run-agent.sh +45 -0
- package/scripts/start-phase.sh +9 -0
- package/skills/acceptance-tests/SKILL.md +38 -0
- package/skills/implement/SKILL.md +39 -0
- package/skills/manage/SKILL.md +70 -0
- package/skills/plan/SKILL.md +41 -0
- package/skills/review/SKILL.md +35 -0
- package/skills/ship/SKILL.md +30 -0
- package/skills/understand/SKILL.md +35 -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 +300 -0
- package/templates/.github/CODEOWNERS +6 -0
- package/templates/.github/workflows/verify.yml +18 -0
- package/templates/.specsail/AGENTS.md +41 -0
- package/templates/.specsail/agents.env.example +15 -0
- package/templates/.specsail/docs/runbook.md +44 -0
- package/templates/.specsail/docs/workflow.md +56 -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 +129 -0
- package/test/cli.test.js +26 -0
- package/test/release.test.js +17 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review
|
|
3
|
+
description: Review a task's or phase's diff against spec and plan. Use after a task reaches DEV_DONE.
|
|
4
|
+
metadata:
|
|
5
|
+
specsail-title: Reviewer
|
|
6
|
+
specsail-crew: Bosun
|
|
7
|
+
---
|
|
8
|
+
# Reviewer — Bosun (review)
|
|
9
|
+
**Model tier:** frontier, preferably a different model family than the implementer. **Report only, never fix.**
|
|
10
|
+
|
|
11
|
+
## Conversational mode
|
|
12
|
+
The First Mate (orchestrator) asks the Captain whether to run this review in a second session/model. If the Captain accepts a same-model review, record that caveat at the top of `review.md`. Never claim independent review when the same model implemented and reviewed.
|
|
13
|
+
|
|
14
|
+
## Reads
|
|
15
|
+
`AGENTS.md`, `specs/<feature>/spec.md`, `plan.md`, `tasks.md`, the diff against the base branch, `docs/architecture.md` (invariants + boundaries), module doc for touched modules.
|
|
16
|
+
|
|
17
|
+
## Writes
|
|
18
|
+
`specs/<feature>/review.md` (only when changes are needed) and the task status in `tasks.md`.
|
|
19
|
+
|
|
20
|
+
## Checklist (evidence required: file:line)
|
|
21
|
+
1. `make verify` is green (run it; don't trust the report).
|
|
22
|
+
2. Each AC of the task has a passing test that really exercises it (not trivially passing).
|
|
23
|
+
3. Behavior matches the spec, including Out of scope: no scope creep, no missing cases.
|
|
24
|
+
4. Architecture boundaries and invariants respected.
|
|
25
|
+
5. Protected paths untouched (`make check-protected`).
|
|
26
|
+
6. Docs listed in plan.md updated.
|
|
27
|
+
7. Correctness, error handling, security, obvious perf traps in the changed code.
|
|
28
|
+
|
|
29
|
+
## Outcome
|
|
30
|
+
- No BLOCKER/MAJOR → set `DONE`. NITs may be listed in the report but don't block.
|
|
31
|
+
- Otherwise → write `review.md` from the template (overwrite previous), set `CHANGES_REQUESTED`, increment loops in `tasks.md`.
|
|
32
|
+
- Loops reaches 3 → don't request again; write `blocked.md` summarizing the disagreement and stop.
|
|
33
|
+
|
|
34
|
+
## Must not
|
|
35
|
+
Edit code or tests; approve without running verify; raise findings without evidence; review style already covered by lint.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ship
|
|
3
|
+
description: Prepare a phase for merge: docs, changelog, PR description. Use after all tasks of a phase are DONE. Prefer scripts for anything mechanical.
|
|
4
|
+
metadata:
|
|
5
|
+
specsail-title: Release
|
|
6
|
+
specsail-crew: Clerk
|
|
7
|
+
---
|
|
8
|
+
# Release — Clerk (ship)
|
|
9
|
+
**Model tier:** cheap. **Mostly mechanical; the Captain merges.**
|
|
10
|
+
|
|
11
|
+
## Conversational mode
|
|
12
|
+
The First Mate (orchestrator) runs the mechanical steps and reports the PR link. The Captain reviews the diff and merges; never merge on the Captain's behalf.
|
|
13
|
+
|
|
14
|
+
## Reads
|
|
15
|
+
`AGENTS.md`, `specs/<feature>/spec.md`, `plan.md`, `tasks.md`, the phase diff, current README / API docs / changelog / `docs/project.md` / `docs/architecture.md`.
|
|
16
|
+
|
|
17
|
+
## Writes
|
|
18
|
+
Docs only, in the same PR: changelog entry, README, API docs, architecture/project/module docs where the diff changed reality. Commit prefix `docs:`.
|
|
19
|
+
|
|
20
|
+
## Process
|
|
21
|
+
1. Confirm every task in the phase is `DONE` and `make verify` is green. If not, stop.
|
|
22
|
+
2. Compare the diff to the docs; update only what became false or incomplete. Keep project.md/architecture.md short.
|
|
23
|
+
3. Add a changelog entry from the spec Objective and ACs (user-facing language).
|
|
24
|
+
4. Run `scripts/open-pr.sh <feature> <phase>`; the PR description links spec.md and lists AC coverage.
|
|
25
|
+
|
|
26
|
+
## Must not
|
|
27
|
+
Merge; edit code, tests, spec or plan; grow docs with history or status.
|
|
28
|
+
|
|
29
|
+
## Done when
|
|
30
|
+
PR is open, docs are current, and the human has what they need to merge.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: understand
|
|
3
|
+
description: Turn a raw request into specs/<feature>/request.md. Use at the start of any feature, update or fix.
|
|
4
|
+
metadata:
|
|
5
|
+
specsail-title: Product Owner
|
|
6
|
+
specsail-crew: Scout
|
|
7
|
+
---
|
|
8
|
+
# Product Owner — Scout (understand)
|
|
9
|
+
**Model tier:** frontier. **Interactive with the human.**
|
|
10
|
+
|
|
11
|
+
## Conversational mode
|
|
12
|
+
The First Mate (orchestrator) may hand you an already-started conversation. Ask the Captain in small batches, most important first, and stop when the request is confirmed; the orchestrator handles the remaining stages.
|
|
13
|
+
|
|
14
|
+
## Reads
|
|
15
|
+
`AGENTS.md`, `docs/project.md`, `docs/architecture.md`, the raw request, `specs/<feature>/request.md` (template).
|
|
16
|
+
|
|
17
|
+
## Writes
|
|
18
|
+
`specs/<feature>/request.md` only.
|
|
19
|
+
|
|
20
|
+
## Process
|
|
21
|
+
1. Read the docs, then the request. Look at code only to confirm which modules are involved.
|
|
22
|
+
2. Interrogate the human before writing: ask about goal, users, edge cases, constraints. Ask in small batches, most important first.
|
|
23
|
+
3. Fill the template. Decide Type and Routing flags honestly; they select the path (see `docs/workflow.md`).
|
|
24
|
+
4. List unresolved questions under "Open questions".
|
|
25
|
+
|
|
26
|
+
## Must not
|
|
27
|
+
- Write spec, plan, tests or code.
|
|
28
|
+
- Guess an answer to an open question; ask.
|
|
29
|
+
- Invent modules; use names from the project.md repo map.
|
|
30
|
+
|
|
31
|
+
## Done when
|
|
32
|
+
Every template field is filled, Open questions is empty or explicitly deferred by the human, and the human confirmed the request.
|
|
33
|
+
|
|
34
|
+
## Escalate when
|
|
35
|
+
The request conflicts with a non-goal or invariant in project.md/architecture.md. Say so and stop.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Blocked: {{SLUG}} / T-?
|
|
2
|
+
Raised by: <role>
|
|
3
|
+
Date:
|
|
4
|
+
|
|
5
|
+
## What I was trying to do
|
|
6
|
+
|
|
7
|
+
## Why I can't proceed
|
|
8
|
+
<!-- Quote the spec line / AC ID that is ambiguous, contradictory or impossible. -->
|
|
9
|
+
|
|
10
|
+
## Options I see
|
|
11
|
+
1.
|
|
12
|
+
2.
|
|
13
|
+
|
|
14
|
+
## Recommendation
|
|
15
|
+
<!-- A human resolves this and, if needed, updates spec.md. -->
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Plan: {{SLUG}}
|
|
2
|
+
<!-- Branch and PR creation are scripted (scripts/start-phase.sh, scripts/open-pr.sh). Don't list them as tasks. -->
|
|
3
|
+
|
|
4
|
+
## Overview
|
|
5
|
+
|
|
6
|
+
## Architecture decisions
|
|
7
|
+
<!-- Omit if request.md says Architecture affected: NO. Link or create ADRs for significant ones. -->
|
|
8
|
+
|
|
9
|
+
## API contract
|
|
10
|
+
<!-- Multi-app only: define first. Endpoints, payloads, errors. -->
|
|
11
|
+
|
|
12
|
+
## Phases
|
|
13
|
+
|
|
14
|
+
### Phase 1: <name>
|
|
15
|
+
- **T-1** <title>
|
|
16
|
+
- AC: AC-1
|
|
17
|
+
- Scope: <modules/files>
|
|
18
|
+
- Verify: `make verify` + <specific command>
|
|
19
|
+
|
|
20
|
+
## Docs to update (same PR)
|
|
21
|
+
<!-- README, API docs, changelog, architecture.md, project.md, module docs. -->
|
|
22
|
+
|
|
23
|
+
## Risks and mitigations
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Request: {{SLUG}}
|
|
2
|
+
Type: {{TYPE}}
|
|
3
|
+
|
|
4
|
+
## Overview
|
|
5
|
+
<!-- What is being asked and why, in 3-6 lines. -->
|
|
6
|
+
|
|
7
|
+
## Routing
|
|
8
|
+
| Question | Answer |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Architecture affected | YES / NO |
|
|
11
|
+
| API affected | YES / NO |
|
|
12
|
+
| UI/UX affected | YES / NO |
|
|
13
|
+
| Apps involved | BACKEND / FRONTEND / MOBILE |
|
|
14
|
+
| Modules involved | <names matching docs/project.md repo map> |
|
|
15
|
+
|
|
16
|
+
## Constraints and risks
|
|
17
|
+
|
|
18
|
+
## Open questions
|
|
19
|
+
<!-- Must be empty (or answered) before moving to spec. -->
|
|
@@ -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,300 @@
|
|
|
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 skillRoles = ["manage", ...stageRoles];
|
|
9
|
+
const typeNames = {
|
|
10
|
+
fix: "FIX",
|
|
11
|
+
update: "FEATURE UPDATE",
|
|
12
|
+
new: "NEW FEATURE",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const help = `SpecSail — navigate by spec, deliver with confidence.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
specsail init Add SpecSail to the current project
|
|
19
|
+
specsail new <slug> <fix|update|new>
|
|
20
|
+
Create a feature's request/spec/plan/tasks
|
|
21
|
+
specsail doctor Check the current project's SpecSail setup
|
|
22
|
+
specsail --version Print the installed version
|
|
23
|
+
specsail --help Show this help
|
|
24
|
+
|
|
25
|
+
The Captain is always human. SpecSail does not approve specs/tests or merge PRs.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function writeLine(stream, text) {
|
|
29
|
+
stream(`${text}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function exists(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
await access(filePath, constants.F_OK);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function lstatIfExists(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
return await lstat(filePath);
|
|
44
|
+
} catch (failure) {
|
|
45
|
+
if (failure.code === "ENOENT") return undefined;
|
|
46
|
+
throw failure;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureSafeDirectory(projectRoot, directory) {
|
|
51
|
+
const relative = path.relative(projectRoot, directory);
|
|
52
|
+
if (relative && (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) {
|
|
53
|
+
throw new Error(`Refusing to write outside the project: ${directory}`);
|
|
54
|
+
}
|
|
55
|
+
let current = projectRoot;
|
|
56
|
+
for (const segment of relative ? relative.split(path.sep) : []) {
|
|
57
|
+
current = path.join(current, segment);
|
|
58
|
+
try {
|
|
59
|
+
const details = await lstat(current);
|
|
60
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
61
|
+
throw new Error(`Refusing to write through a symlink or non-directory path: ${current}`);
|
|
62
|
+
}
|
|
63
|
+
} catch (failure) {
|
|
64
|
+
if (failure.code !== "ENOENT") throw failure;
|
|
65
|
+
await mkdir(current);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function copyTree(source, destination, { suffixes, projectRoot } = {}) {
|
|
71
|
+
let created = 0;
|
|
72
|
+
let skipped = 0;
|
|
73
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const sourcePath = path.join(source, entry.name);
|
|
76
|
+
const destinationPath = path.join(destination, entry.name);
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
await ensureSafeDirectory(projectRoot, destinationPath);
|
|
79
|
+
const result = await copyTree(sourcePath, destinationPath, { suffixes, projectRoot });
|
|
80
|
+
created += result.created;
|
|
81
|
+
skipped += result.skipped;
|
|
82
|
+
} else if (entry.isFile() && (!suffixes || suffixes.some((suffix) => entry.name.endsWith(suffix)))) {
|
|
83
|
+
await ensureSafeDirectory(projectRoot, path.dirname(destinationPath));
|
|
84
|
+
try {
|
|
85
|
+
const sourceStat = await stat(sourcePath);
|
|
86
|
+
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL);
|
|
87
|
+
await access(destinationPath, constants.F_OK);
|
|
88
|
+
if (process.platform !== "win32") await import("node:fs/promises").then(({ chmod }) => chmod(destinationPath, sourceStat.mode));
|
|
89
|
+
created += 1;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.code === "EEXIST") skipped += 1;
|
|
92
|
+
else throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { created, skipped };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function copyFileIfMissing(source, destination, projectRoot) {
|
|
100
|
+
await ensureSafeDirectory(projectRoot, path.dirname(destination));
|
|
101
|
+
try {
|
|
102
|
+
const sourceStat = await stat(source);
|
|
103
|
+
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
|
104
|
+
if (process.platform !== "win32") await import("node:fs/promises").then(({ chmod }) => chmod(destination, sourceStat.mode));
|
|
105
|
+
return true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code === "EEXIST") return false;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function updateManagedBlock(filePath, marker, text, projectRoot) {
|
|
113
|
+
await ensureSafeDirectory(projectRoot, path.dirname(filePath));
|
|
114
|
+
let current = "";
|
|
115
|
+
const details = await lstatIfExists(filePath);
|
|
116
|
+
if (details) {
|
|
117
|
+
if (details.isSymbolicLink() || !details.isFile()) throw new Error(`Refusing to modify symlink or non-file: ${filePath}`);
|
|
118
|
+
current = await readFile(filePath, "utf8");
|
|
119
|
+
}
|
|
120
|
+
const [start, end] = marker;
|
|
121
|
+
const startCount = current.split(start).length - 1;
|
|
122
|
+
const endCount = current.split(end).length - 1;
|
|
123
|
+
if (startCount !== endCount || startCount > 1) {
|
|
124
|
+
throw new Error(`Cannot safely update managed section in ${path.relative(process.cwd(), filePath)}: markers are unbalanced or duplicated.`);
|
|
125
|
+
}
|
|
126
|
+
const section = `${start}\n${text.trim()}\n${end}`;
|
|
127
|
+
if (startCount === 1) {
|
|
128
|
+
const escapedStart = start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
const escapedEnd = end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
130
|
+
current = current.replace(new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`), section);
|
|
131
|
+
} else {
|
|
132
|
+
current = `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${section}\n`;
|
|
133
|
+
}
|
|
134
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
135
|
+
await writeFile(filePath, current, "utf8");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function loadInstalledConfig(configPath) {
|
|
139
|
+
try {
|
|
140
|
+
const details = await lstatIfExists(configPath);
|
|
141
|
+
if (!details) return {};
|
|
142
|
+
if (details.isSymbolicLink() || !details.isFile()) throw new Error("expected a regular file, not a symlink");
|
|
143
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
144
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected a JSON object");
|
|
145
|
+
return parsed;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw new Error(`Cannot read .specsail.json: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function init(cwd, output, root) {
|
|
152
|
+
const configPath = path.join(cwd, ".specsail.json");
|
|
153
|
+
const config = await loadInstalledConfig(configPath);
|
|
154
|
+
const operations = [
|
|
155
|
+
[path.join(root, "templates/.specsail"), path.join(cwd, ".specsail")],
|
|
156
|
+
[path.join(root, "skills"), path.join(cwd, ".specsail/skills")],
|
|
157
|
+
[path.join(root, "scripts"), path.join(cwd, ".specsail/scripts"), { suffixes: [".sh"] }],
|
|
158
|
+
[path.join(root, "specs/_templates"), path.join(cwd, ".specsail/templates/specs/_templates")],
|
|
159
|
+
];
|
|
160
|
+
let created = 0;
|
|
161
|
+
let skipped = 0;
|
|
162
|
+
for (const [source, destination, options] of operations) {
|
|
163
|
+
const result = await copyTree(source, destination, { ...options, projectRoot: cwd });
|
|
164
|
+
created += result.created;
|
|
165
|
+
skipped += result.skipped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const individualFiles = [
|
|
169
|
+
[path.join(root, "templates/Makefile"), path.join(cwd, "Makefile")],
|
|
170
|
+
[path.join(root, "templates/docs/project.md"), path.join(cwd, "docs/project.md")],
|
|
171
|
+
[path.join(root, "templates/docs/architecture.md"), path.join(cwd, "docs/architecture.md")],
|
|
172
|
+
[path.join(root, "templates/docs/architecture/.gitkeep"), path.join(cwd, "docs/architecture/.gitkeep")],
|
|
173
|
+
[path.join(root, "templates/.github/CODEOWNERS"), path.join(cwd, ".github/CODEOWNERS")],
|
|
174
|
+
[path.join(root, "templates/.github/workflows/verify.yml"), path.join(cwd, ".github/workflows/verify.yml")],
|
|
175
|
+
];
|
|
176
|
+
for (const [source, destination] of individualFiles) {
|
|
177
|
+
if (await copyFileIfMissing(source, destination, cwd)) created += 1;
|
|
178
|
+
else skipped += 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await updateManagedBlock(
|
|
182
|
+
path.join(cwd, "AGENTS.md"),
|
|
183
|
+
["<!-- specsail:start -->", "<!-- specsail:end -->"],
|
|
184
|
+
"## SpecSail\n\nThis project uses SpecSail, a Captain-led, spec-driven development workflow. When the user asks for a non-trivial feature, fix, or update, start the workflow before writing code: read .specsail/skills/manage/SKILL.md and follow it. The First Mate orchestrates the crew; the human Captain answers questions, approves specs and acceptance tests, and merges. Commands are optional. Read .specsail/AGENTS.md for the crew and rules.",
|
|
185
|
+
cwd,
|
|
186
|
+
);
|
|
187
|
+
await updateManagedBlock(
|
|
188
|
+
path.join(cwd, ".gitignore"),
|
|
189
|
+
["# >>> SpecSail >>>", "# <<< SpecSail <<<"],
|
|
190
|
+
".specsail/agents.env",
|
|
191
|
+
cwd,
|
|
192
|
+
);
|
|
193
|
+
config.version = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")).version;
|
|
194
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
195
|
+
writeLine(output, `SpecSail initialized (${config.version}). Added ${created} file(s); left ${skipped} existing file(s) unchanged.`);
|
|
196
|
+
writeLine(output, "Next: open your coding agent and ask for a change. The First Mate will check the project docs and Makefile, ask what is missing, and start the workflow.");
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validSlug(slug) {
|
|
201
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function newFeature(cwd, args, output) {
|
|
205
|
+
const [slug, kind, ...extra] = args;
|
|
206
|
+
if (!slug || !kind || extra.length || !validSlug(slug) || !Object.hasOwn(typeNames, kind)) {
|
|
207
|
+
throw new Error("Usage: specsail new <lowercase-kebab-slug> <fix|update|new>");
|
|
208
|
+
}
|
|
209
|
+
const specsailRoot = path.join(cwd, ".specsail");
|
|
210
|
+
const templates = path.join(specsailRoot, "templates/specs/_templates");
|
|
211
|
+
if (!(await exists(templates))) throw new Error("SpecSail is not initialized here. Run `specsail init` first.");
|
|
212
|
+
const destination = path.join(cwd, "specs", slug);
|
|
213
|
+
if (await exists(destination)) throw new Error(`specs/${slug} already exists; refusing to overwrite it.`);
|
|
214
|
+
const entries = ["request.md", "spec.md", "plan.md", "tasks.md"];
|
|
215
|
+
await ensureSafeDirectory(cwd, path.join(cwd, "specs"));
|
|
216
|
+
await mkdir(destination, { recursive: true });
|
|
217
|
+
try {
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const source = await readFile(path.join(templates, entry), "utf8");
|
|
220
|
+
const content = source.replaceAll("{{SLUG}}", slug).replaceAll("{{TYPE}}", typeNames[kind]);
|
|
221
|
+
await writeFile(path.join(destination, entry), content, { flag: "wx" });
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new Error(`Could not scaffold specs/${slug}: ${error.message}. Remove the partial directory before retrying.`);
|
|
225
|
+
}
|
|
226
|
+
writeLine(output, `Created specs/${slug}/. Next: ask your agent to start the SpecSail workflow for ${slug}; the First Mate will continue from the request stage.`);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function hasGitRepository(cwd) {
|
|
231
|
+
let current = cwd;
|
|
232
|
+
while (true) {
|
|
233
|
+
if (await exists(path.join(current, ".git"))) return true;
|
|
234
|
+
const parent = path.dirname(current);
|
|
235
|
+
if (parent === current) return false;
|
|
236
|
+
current = parent;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function doctor(cwd, output, error) {
|
|
241
|
+
const failures = [];
|
|
242
|
+
const checks = [
|
|
243
|
+
[".specsail.json", path.join(cwd, ".specsail.json")],
|
|
244
|
+
[".specsail/AGENTS.md", path.join(cwd, ".specsail/AGENTS.md")],
|
|
245
|
+
["AGENTS.md SpecSail section", path.join(cwd, "AGENTS.md")],
|
|
246
|
+
];
|
|
247
|
+
for (const [label, filePath] of checks) {
|
|
248
|
+
if (!(await exists(filePath))) failures.push(`Missing ${label}; run specsail init.`);
|
|
249
|
+
}
|
|
250
|
+
for (const stage of skillRoles) {
|
|
251
|
+
const skill = path.join(cwd, ".specsail/skills", stage, "SKILL.md");
|
|
252
|
+
if (!(await exists(skill))) failures.push(`Missing ${path.relative(cwd, skill)}; run specsail init.`);
|
|
253
|
+
}
|
|
254
|
+
const makefile = path.join(cwd, "Makefile");
|
|
255
|
+
const makefileContent = await exists(makefile) ? await readFile(makefile, "utf8") : "";
|
|
256
|
+
if (!/^verify\s*:/m.test(makefileContent)) {
|
|
257
|
+
failures.push("Makefile must define a verify target. Configure it for this project's stack.");
|
|
258
|
+
} else if (makefileContent.includes("SPECSAIL_UNCONFIGURED_TEMPLATE")) {
|
|
259
|
+
failures.push("Makefile still contains the SpecSail starter template. Configure setup, lint, typecheck, test, and accept before relying on make verify.");
|
|
260
|
+
}
|
|
261
|
+
const agents = await exists(path.join(cwd, "AGENTS.md")) ? await readFile(path.join(cwd, "AGENTS.md"), "utf8") : "";
|
|
262
|
+
if (!agents.includes("<!-- specsail:start -->") || !agents.includes("<!-- specsail:end -->")) {
|
|
263
|
+
failures.push("AGENTS.md is missing the SpecSail managed section; run specsail init.");
|
|
264
|
+
} else if (!agents.includes("skills/manage/SKILL.md")) {
|
|
265
|
+
failures.push("AGENTS.md does not start the conversational workflow; update the SpecSail section or run specsail init again.");
|
|
266
|
+
}
|
|
267
|
+
if (!(await hasGitRepository(cwd))) writeLine(error, "Warning: no .git directory found. Initialize version control before starting a feature.");
|
|
268
|
+
if (failures.length) {
|
|
269
|
+
for (const failure of failures) writeLine(error, `✗ ${failure}`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
writeLine(output, "✓ SpecSail files and verify target are present.");
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function runCli(args, options = {}) {
|
|
277
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
278
|
+
const root = path.resolve(options.packageRoot ?? packageRoot);
|
|
279
|
+
const output = options.stdout ?? ((text) => process.stdout.write(text));
|
|
280
|
+
const error = options.stderr ?? ((text) => process.stderr.write(text));
|
|
281
|
+
const [command, ...rest] = args;
|
|
282
|
+
try {
|
|
283
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
284
|
+
writeLine(output, help);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
288
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
289
|
+
writeLine(output, manifest.version);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
if (command === "init") return await init(cwd, output, root);
|
|
293
|
+
if (command === "new") return await newFeature(cwd, rest, output);
|
|
294
|
+
if (command === "doctor") return await doctor(cwd, output, error);
|
|
295
|
+
throw new Error(`Unknown command: ${command}. Run specsail --help for usage.`);
|
|
296
|
+
} catch (failure) {
|
|
297
|
+
writeLine(error, `Error: ${failure.message}`);
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -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,41 @@
|
|
|
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
|
+
## Conversational mode
|
|
6
|
+
|
|
7
|
+
When the user asks for a non-trivial change, fix, or feature, run the workflow instead of writing code immediately:
|
|
8
|
+
|
|
9
|
+
1. Read `.specsail/skills/manage/SKILL.md` and follow it.
|
|
10
|
+
2. If `docs/project.md`, `docs/architecture.md`, or the `Makefile` verify target are missing or unconfigured, bootstrap them with the Captain's answers first.
|
|
11
|
+
3. Run the stages in order and stop at each gate. Chat approvals are recorded in the artifact with the Captain's name and date.
|
|
12
|
+
|
|
13
|
+
The user does not need to run commands. `npx specsail init/new/doctor` and `.specsail/scripts/*` are optional shortcuts.
|
|
14
|
+
|
|
15
|
+
## Crew and skills
|
|
16
|
+
|
|
17
|
+
| Skill | Development role | Crew alias | Purpose |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| `manage` | Orchestrator | First Mate | Runs the workflow, bootstraps the project, sequences stages and gates. |
|
|
20
|
+
| `understand` | Product Owner | Scout | Turns the request into `request.md`. |
|
|
21
|
+
| `plan` | Tech Lead | Navigator | Writes `spec.md`, then `plan.md` and `tasks.md`. |
|
|
22
|
+
| `acceptance-tests` | QA | Quartermaster | Writes failing acceptance tests from approved criteria. |
|
|
23
|
+
| `implement` | Developer | Hands | Implements one planned task. |
|
|
24
|
+
| `review` | Reviewer | Bosun | Reviews independently with evidence. |
|
|
25
|
+
| `ship` | Release | Clerk | Updates docs, changelog, and opens the PR. |
|
|
26
|
+
|
|
27
|
+
The Captain is a human, not an agent. If an agent tool cannot load skills automatically, tell it to read `.specsail/skills/<skill>/SKILL.md` directly.
|
|
28
|
+
|
|
29
|
+
## Workflow
|
|
30
|
+
|
|
31
|
+
`request → approved spec → plan → approved acceptance tests (per phase) → implement one task → review → ship`
|
|
32
|
+
|
|
33
|
+
- Read `docs/project.md` and `docs/architecture.md` before scoping project work. Add module documentation under `docs/architecture/` when needed.
|
|
34
|
+
- Feature artifacts live in `specs/<feature>/`; templates are in `.specsail/templates/specs/_templates/`.
|
|
35
|
+
- 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.
|
|
36
|
+
- 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.
|
|
37
|
+
- Implement one planned task at a time. Do not broaden scope. Run `make verify` before marking a task `DEV_DONE`.
|
|
38
|
+
- Review is independent of implementation and reports evidence; it does not fix code. The Captain merges.
|
|
39
|
+
- Update docs in the same PR as the code change.
|
|
40
|
+
|
|
41
|
+
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,44 @@
|
|
|
1
|
+
# SpecSail runbook
|
|
2
|
+
|
|
3
|
+
Step-by-step operating guide. `.specsail/docs/workflow.md` is the concise reference.
|
|
4
|
+
|
|
5
|
+
## Talk to the First Mate
|
|
6
|
+
|
|
7
|
+
In your coding agent, ask for the change in normal language: *"let's add user authentication"* or *"fix the login lockout bug."* The agent reads `.specsail/skills/manage/SKILL.md` and starts the workflow:
|
|
8
|
+
|
|
9
|
+
1. Checks `docs/project.md`, `docs/architecture.md`, and the `Makefile` verify target. If they are missing or still starter templates, it interviews you and drafts them.
|
|
10
|
+
2. Scaffolds `specs/<slug>/`, asks any questions, and runs Scout, Navigator, Quartermaster, Hands, Bosun, and Clerk in order.
|
|
11
|
+
3. Stops at each gate: request confirmed, spec approved, tests approved, architecture approved when affected, and merge. Approve in chat; the agent records the decision with your name and date.
|
|
12
|
+
|
|
13
|
+
Commands are optional. Use them if you prefer manual control, CI, or scripting.
|
|
14
|
+
|
|
15
|
+
## Run stages manually (optional)
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
./.specsail/scripts/start-phase.sh <slug> 1
|
|
19
|
+
./.specsail/scripts/new-feature.sh <slug> <fix|update|new>
|
|
20
|
+
./.specsail/scripts/run-agent.sh scout <slug>
|
|
21
|
+
./.specsail/scripts/run-agent.sh navigator <slug>
|
|
22
|
+
./.specsail/scripts/run-agent.sh quartermaster <slug> "AC-1,AC-2"
|
|
23
|
+
./.specsail/scripts/run-agent.sh hands <slug> T-1
|
|
24
|
+
./.specsail/scripts/run-agent.sh bosun <slug> T-1
|
|
25
|
+
./.specsail/scripts/run-agent.sh clerk <slug>
|
|
26
|
+
./.specsail/scripts/open-pr.sh <slug> 1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Stage names also work: `understand`, `plan`, `acceptance-tests`, `implement`, `review`, `ship`. The orchestrator is `manage` / `first-mate`.
|
|
30
|
+
|
|
31
|
+
## Gates and review
|
|
32
|
+
|
|
33
|
+
- **Spec:** the Captain reviews every acceptance criterion and approves before planning.
|
|
34
|
+
- **Tests:** the Captain checks the phase's tests fail for the right reason before implementation.
|
|
35
|
+
- **Review:** the First Mate asks whether to use a second session/model. If you accept same-model review, the caveat is recorded in `review.md`.
|
|
36
|
+
- **Merge:** the Captain checks CI, protected paths, and docs, then merges.
|
|
37
|
+
|
|
38
|
+
For later phases, create the phase branch with `start-phase.sh <slug> <phase>`, then write and approve that phase's acceptance tests before implementation.
|
|
39
|
+
|
|
40
|
+
## Optional headless runner
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
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.
|