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.
Files changed (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -2
  4. package/ROADMAP.md +18 -0
  5. package/bin/specsail.js +4 -0
  6. package/package.json +51 -4
  7. package/scripts/check-content.mjs +80 -0
  8. package/scripts/check-protected-paths.sh +20 -0
  9. package/scripts/new-feature.sh +25 -0
  10. package/scripts/open-pr.sh +15 -0
  11. package/scripts/release.sh +29 -0
  12. package/scripts/run-agent.sh +45 -0
  13. package/scripts/start-phase.sh +9 -0
  14. package/skills/acceptance-tests/SKILL.md +38 -0
  15. package/skills/implement/SKILL.md +39 -0
  16. package/skills/manage/SKILL.md +70 -0
  17. package/skills/plan/SKILL.md +41 -0
  18. package/skills/review/SKILL.md +35 -0
  19. package/skills/ship/SKILL.md +30 -0
  20. package/skills/understand/SKILL.md +35 -0
  21. package/specs/_templates/blocked.md +15 -0
  22. package/specs/_templates/plan.md +23 -0
  23. package/specs/_templates/request.md +19 -0
  24. package/specs/_templates/review.md +18 -0
  25. package/specs/_templates/spec.md +20 -0
  26. package/specs/_templates/tasks.md +7 -0
  27. package/src/cli.js +300 -0
  28. package/templates/.github/CODEOWNERS +6 -0
  29. package/templates/.github/workflows/verify.yml +18 -0
  30. package/templates/.specsail/AGENTS.md +41 -0
  31. package/templates/.specsail/agents.env.example +15 -0
  32. package/templates/.specsail/docs/runbook.md +44 -0
  33. package/templates/.specsail/docs/workflow.md +56 -0
  34. package/templates/Makefile +19 -0
  35. package/templates/docs/architecture/.gitkeep +0 -0
  36. package/templates/docs/architecture.md +15 -0
  37. package/templates/docs/project.md +19 -0
  38. package/test/acceptance/cli.acceptance.test.js +129 -0
  39. package/test/cli.test.js +26 -0
  40. package/test/release.test.js +17 -0
@@ -0,0 +1,56 @@
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. The First Mate (`manage`) runs the workflow conversationally; the other stages are role lenses it adopts.
4
+
5
+ ## Stages and crew
6
+
7
+ | # | Skill | Development role — crew alias | Reads | Writes |
8
+ |---|---|---|---|---|
9
+ | 0 | manage | Orchestrator — First Mate | project docs, Makefile, feature state | bootstrap docs, sequencing, gate records |
10
+ | 1 | understand | Product Owner — Scout | project, architecture, request | `request.md` |
11
+ | 2a | plan (spec mode) | Tech Lead — Navigator | project, architecture, request | `spec.md` |
12
+ | 2b | plan (plan mode) | Tech Lead — Navigator | approved spec, module docs | `plan.md`, `tasks.md` |
13
+ | 3 | acceptance-tests | QA — Quartermaster | approved spec, glossary, public interfaces | `tests/acceptance/<feature>/**` |
14
+ | 4 | implement | Developer — Hands | spec, plan, tasks, review, module docs | code, unit tests, task status |
15
+ | 5 | review | Reviewer — Bosun | spec, plan, tasks, diff, invariants | review report, task status |
16
+ | 6 | ship | Release — Clerk | spec, plan, tasks, diff | docs, changelog, PR |
17
+
18
+ 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.
19
+
20
+ ## Conversational operation
21
+
22
+ The Captain starts with a normal request ("let's add user authentication"). The First Mate checks project context, bootstraps missing docs/Makefile settings with the Captain's answers, scaffolds the feature, then runs the stages in order. The Captain answers questions and approves artifacts in chat; commands are optional.
23
+
24
+ **Recording approvals:** on explicit approval, the agent records `Status: APPROVED — Captain (chat), YYYY-MM-DD` in the artifact. The Captain's decision is the approval; the file is the evidence. Silence is not approval.
25
+
26
+ **Review independence:** the First Mate asks the Captain whether to review in a second session/model. If the Captain accepts a same-model review, the caveat is recorded in `review.md`.
27
+
28
+ ## Change paths
29
+
30
+ | Type | Path |
31
+ |---|---|
32
+ | FIX | understand → spec with a reproduction criterion → acceptance test → implement → review → ship. Skip architecture planning unless needed. |
33
+ | FEATURE UPDATE | full flow; plan skips architecture decisions if `Architecture affected: NO`. |
34
+ | NEW FEATURE | full flow. |
35
+ | Trivial (typo, config, dependency bump) | Normal PR and `make verify`; no feature artifacts. |
36
+
37
+ ## Human gates
38
+
39
+ 1. Confirm the request and resolve or explicitly defer open questions.
40
+ 2. Review and approve `spec.md` before planning.
41
+ 3. Review and approve acceptance tests for the current phase before implementation.
42
+ 4. Merge only after CI is green, review is done, protected paths are clean, and docs are current.
43
+
44
+ Architecture decisions require Captain approval when the plan says architecture is affected. Merge is always a human decision.
45
+
46
+ ## Task states and done
47
+
48
+ `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.
49
+
50
+ A task is done when `make verify` is green, linked acceptance criteria pass, required docs are updated, and the Bosun marks it `DONE`.
51
+
52
+ ## Parallel work and review
53
+
54
+ - Define API contracts before parallel multi-app work; independent tasks may use separate worktrees.
55
+ - Do not parallelize tasks that touch the same module or shared task-status rows.
56
+ - CI is deterministic verification; Bosun 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
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { test } from "node:test";
6
+ import { runCli } from "../../src/cli.js";
7
+
8
+ const packageRoot = new URL("../../", import.meta.url).pathname;
9
+ const manifest = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
10
+
11
+ async function temporaryProject() {
12
+ return mkdtemp(path.join(os.tmpdir(), "specsail-test-"));
13
+ }
14
+
15
+ function streams() {
16
+ let stdout = "";
17
+ let stderr = "";
18
+ return {
19
+ options: {
20
+ packageRoot,
21
+ stdout: (text) => { stdout += text; },
22
+ stderr: (text) => { stderr += text; },
23
+ },
24
+ stdout: () => stdout,
25
+ stderr: () => stderr,
26
+ };
27
+ }
28
+
29
+ test("init preserves project-owned files, installs the crew, and is repeatable", async (context) => {
30
+ const cwd = await temporaryProject();
31
+ context.after(() => rm(cwd, { recursive: true, force: true }));
32
+ await mkdir(path.join(cwd, "docs"), { recursive: true });
33
+ await writeFile(path.join(cwd, "AGENTS.md"), "# Existing project rules\n", "utf8");
34
+ await writeFile(path.join(cwd, "Makefile"), "verify:\n\t@echo project checks\n", "utf8");
35
+ await writeFile(path.join(cwd, "docs/project.md"), "# My existing project\n", "utf8");
36
+ await writeFile(path.join(cwd, ".specsail.json"), JSON.stringify({ version: "old", localSetting: true }), "utf8");
37
+ const first = streams();
38
+
39
+ assert.equal(await runCli(["init"], { cwd, ...first.options }), 0);
40
+ assert.equal(await readFile(path.join(cwd, "Makefile"), "utf8"), "verify:\n\t@echo project checks\n");
41
+ assert.equal(await readFile(path.join(cwd, "docs/project.md"), "utf8"), "# My existing project\n");
42
+ assert.match(await readFile(path.join(cwd, "AGENTS.md"), "utf8"), /<!-- specsail:start -->/);
43
+ assert.match(await readFile(path.join(cwd, ".specsail/skills/understand/SKILL.md"), "utf8"), /Scout/);
44
+ assert.match(await readFile(path.join(cwd, ".specsail/skills/manage/SKILL.md"), "utf8"), /First Mate/);
45
+ assert.match(await readFile(path.join(cwd, ".specsail/skills/review/SKILL.md"), "utf8"), /Bosun/);
46
+ assert.match(await readFile(path.join(cwd, ".specsail/skills/ship/SKILL.md"), "utf8"), /Clerk/);
47
+ assert.match(await readFile(path.join(cwd, "AGENTS.md"), "utf8"), /skills\/manage\/SKILL\.md/);
48
+ assert.match(await readFile(path.join(cwd, ".gitignore"), "utf8"), /\.specsail\/agents\.env/);
49
+ assert.deepEqual(JSON.parse(await readFile(path.join(cwd, ".specsail.json"), "utf8")), { version: manifest.version, localSetting: true });
50
+
51
+ const second = streams();
52
+ assert.equal(await runCli(["init"], { cwd, ...second.options }), 0);
53
+ const agents = await readFile(path.join(cwd, "AGENTS.md"), "utf8");
54
+ assert.equal(agents.split("<!-- specsail:start -->").length - 1, 1);
55
+ assert.equal(agents.split("<!-- specsail:end -->").length - 1, 1);
56
+ const gitignore = await readFile(path.join(cwd, ".gitignore"), "utf8");
57
+ assert.equal(gitignore.split("# >>> SpecSail >>>").length - 1, 1);
58
+ assert.equal(gitignore.split("# <<< SpecSail <<<").length - 1, 1);
59
+ assert.match(second.stdout(), /left \d+ existing file\(s\) unchanged/);
60
+ });
61
+
62
+ test("new scaffolds only the four feature artifacts and rejects unsafe or duplicate slugs", async (context) => {
63
+ const cwd = await temporaryProject();
64
+ context.after(() => rm(cwd, { recursive: true, force: true }));
65
+ const initStreams = streams();
66
+ assert.equal(await runCli(["init"], { cwd, ...initStreams.options }), 0);
67
+
68
+ const created = streams();
69
+ assert.equal(await runCli(["new", "password-reset", "new"], { cwd, ...created.options }), 0);
70
+ const spec = await readFile(path.join(cwd, "specs/password-reset/spec.md"), "utf8");
71
+ assert.match(spec, /^# Spec: password-reset/m);
72
+ assert.match(await readFile(path.join(cwd, "specs/password-reset/request.md"), "utf8"), /Type: NEW FEATURE/);
73
+ assert.deepEqual((await (await import("node:fs/promises")).readdir(path.join(cwd, "specs/password-reset"))).sort(), ["plan.md", "request.md", "spec.md", "tasks.md"]);
74
+
75
+ const invalid = streams();
76
+ assert.equal(await runCli(["new", "../escape", "fix"], { cwd, ...invalid.options }), 1);
77
+ assert.match(invalid.stderr(), /Usage: specsail new/);
78
+ const duplicate = streams();
79
+ assert.equal(await runCli(["new", "password-reset", "fix"], { cwd, ...duplicate.options }), 1);
80
+ assert.match(duplicate.stderr(), /refusing to overwrite/);
81
+ });
82
+
83
+ test("doctor verifies the kit and the project's verify command", async (context) => {
84
+ const cwd = await temporaryProject();
85
+ context.after(() => rm(cwd, { recursive: true, force: true }));
86
+ const initialized = streams();
87
+ assert.equal(await runCli(["init"], { cwd, ...initialized.options }), 0);
88
+
89
+ const beforeConfig = streams();
90
+ assert.equal(await runCli(["doctor"], { cwd, ...beforeConfig.options }), 1);
91
+ assert.match(beforeConfig.stderr(), /Makefile still contains the SpecSail starter template/);
92
+
93
+ await writeFile(path.join(cwd, "Makefile"), "verify:\n\t@echo configured\n", "utf8");
94
+ const afterConfig = streams();
95
+ assert.equal(await runCli(["doctor"], { cwd, ...afterConfig.options }), 0);
96
+ assert.match(afterConfig.stdout(), /files and verify target are present/);
97
+ });
98
+
99
+ test("doctor flags an AGENTS.md that does not start the conversational workflow", async (context) => {
100
+ const cwd = await temporaryProject();
101
+ context.after(() => rm(cwd, { recursive: true, force: true }));
102
+ const initialized = streams();
103
+ assert.equal(await runCli(["init"], { cwd, ...initialized.options }), 0);
104
+ await writeFile(path.join(cwd, "Makefile"), "verify:\n\t@echo configured\n", "utf8");
105
+ const agentsPath = path.join(cwd, "AGENTS.md");
106
+ await writeFile(agentsPath, (await readFile(agentsPath, "utf8")).replace("skills/manage/SKILL.md", "AGENTS.md"), "utf8");
107
+ const result = streams();
108
+ assert.equal(await runCli(["doctor"], { cwd, ...result.options }), 1);
109
+ assert.match(result.stderr(), /does not start the conversational workflow/);
110
+ });
111
+
112
+ test("init refuses to write through a symlinked SpecSail directory", async (context) => {
113
+ const cwd = await temporaryProject();
114
+ const outside = await temporaryProject();
115
+ context.after(async () => {
116
+ await rm(cwd, { recursive: true, force: true });
117
+ await rm(outside, { recursive: true, force: true });
118
+ });
119
+ try {
120
+ await symlink(outside, path.join(cwd, ".specsail"), "dir");
121
+ } catch (failure) {
122
+ context.skip(`symlink creation unavailable: ${failure.message}`);
123
+ return;
124
+ }
125
+ const result = streams();
126
+ assert.equal(await runCli(["init"], { cwd, ...result.options }), 1);
127
+ assert.match(result.stderr(), /Refusing to write through a symlink/);
128
+ await assert.rejects(readFile(path.join(outside, "skills/understand/SKILL.md")), { code: "ENOENT" });
129
+ });
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { test } from "node:test";
4
+ import { runCli } from "../src/cli.js";
5
+
6
+ const root = new URL("../", import.meta.url).pathname;
7
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
8
+
9
+ test("help and version are available without initializing a project", async () => {
10
+ let stdout = "";
11
+ let stderr = "";
12
+ const options = { packageRoot: root, stdout: (text) => { stdout += text; }, stderr: (text) => { stderr += text; } };
13
+ assert.equal(await runCli(["--help"], options), 0);
14
+ assert.match(stdout, /Captain is always human/);
15
+ assert.equal(await runCli(["--version"], options), 0);
16
+ assert.match(stdout, new RegExp(manifest.version.replaceAll(".", "\\.")));
17
+ assert.equal(stderr, "");
18
+ });
19
+
20
+ test("unknown commands fail with a useful hint", async () => {
21
+ let stderr = "";
22
+ const status = await runCli(["sail"], { stderr: (text) => { stderr += text; } });
23
+ assert.equal(status, 1);
24
+ assert.match(stderr, /Unknown command: sail/);
25
+ assert.match(stderr, /specsail --help/);
26
+ });
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { test } from "node:test";
5
+
6
+ const workflowPath = new URL("../.github/workflows/release.yml", import.meta.url);
7
+ const shipped = existsSync(workflowPath);
8
+
9
+ test("release workflow publishes with OIDC and provenance", { skip: !shipped && "release workflow is not included in the published tarball" }, async () => {
10
+ const workflow = await readFile(workflowPath, "utf8");
11
+ assert.match(workflow, /^on:\s*\n\s*push:\s*\n\s*tags:\s*\["v\*"\]/m);
12
+ assert.match(workflow, /^\s*id-token:\s*write$/m);
13
+ assert.match(workflow, /^\s*contents:\s*write$/m);
14
+ assert.match(workflow, /^\s*environment:\s*npm$/m);
15
+ assert.match(workflow, /npm publish --access public --provenance/);
16
+ assert.doesNotMatch(workflow, /NODE_AUTH_TOKEN|NPM_TOKEN|secrets\./);
17
+ });