specflo 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: spec
3
+ description: Use at a specflo project's spec phase, when turning a validated brainstorm into a structured, testable spec before any plan or code. Triggers include "write the spec", "let's spec this out", or `specflo status` showing the spec phase. Do NOT use for trivial fixes or before a brainstorm has been validated.
4
+ ---
5
+
6
+ # Spec (specflo)
7
+
8
+ ## Overview
9
+
10
+ Turn a validated `brainstorm.md` into a **validated `spec.md`** for the active
11
+ specflo project: an objective, numbered **testable `REQ-NN` requirements** (each
12
+ with a pass/fail acceptance criterion, traced to the `D-NN` decision it derives
13
+ from), explicit in/out boundaries, and open questions — then hand off toward the
14
+ plan phase. The `specflo` CLI does the artifact I/O; you carry the synthesis,
15
+ judgment, and discipline.
16
+
17
+ ## When to use / When NOT
18
+
19
+ **Use** at the spec phase (the second of `brainstorm → spec → plan → execute`),
20
+ once the brainstorm is validated. The spec is a **synthesis of the brainstorm,
21
+ not a new interview** — read `brainstorm.md` and write requirements; do not
22
+ re-litigate settled decisions.
23
+
24
+ **Do NOT use** for trivial fixes, or before a brainstorm exists and passes
25
+ `specflo validate brainstorm`. If the brainstorm is incomplete, finish it first.
26
+
27
+ ## HARD-GATE
28
+
29
+ Do NOT write code, scaffold anything, or take any implementation action until the
30
+ spec is validated (`specflo validate spec` passes) and the user has explicitly
31
+ approved. The discipline here is the **rigor of the artifact** — testable
32
+ requirements with pass/fail acceptance — not merely "don't code yet".
33
+
34
+ ## Process
35
+
36
+ 1. **Preflight.** Confirm an active project at the spec phase (`specflo status`).
37
+ Run `specflo spec start` to create or locate `spec.md` (the command prints its
38
+ path — never build the path yourself). Read the project's `brainstorm.md`:
39
+ its Decisions (`D-NN`), Research, Current understanding, and Out of scope.
40
+ 2. **Draft requirements — translate decisions, reframe vague → testable.** For
41
+ each thing the system must do, write a falsifiable requirement. Two moves:
42
+ - **Decision → behavior.** Brainstorm decisions are often *architecture or
43
+ tech-stack* choices ("build on SDK X", "UI in framework Y"), not behaviors —
44
+ and have no inherent pass/fail. Make each testable by spec'ing the
45
+ **behaviors it exists to enable**, and expect one decision to **fan out into
46
+ several requirements** (e.g. "build on the SDK" → separate auth, discovery,
47
+ and version-compatibility requirements, each `--from` that decision).
48
+ - **Reframe fuzzy wants:** `"make it fast"` →
49
+ `"the landscape scan returns within 5s on a warm cache"`, then ask "are these
50
+ the right targets?".
51
+ A good requirement is **✓** "API responds in < 200ms at p95"; a bad one is
52
+ **✗** "the system should be fast".
53
+ 3. **Give every requirement a pass/fail acceptance criterion.** No subjective
54
+ criteria — write how a verifier confirms it. The check may be **behavioral**
55
+ (observe the running system) or **structural** (a source- or dependency-scan)
56
+ — both are valid as long as the verdict is mechanical. An architectural
57
+ constraint ("all server I/O goes through the SDK; no hand-rolled client") is
58
+ legitimately verified structurally; don't contort it into a fake runtime test.
59
+ Trace it to the decision it comes from with `--from D-NN` where applicable.
60
+ 4. **Capture inline.** The moment a requirement is settled, record it:
61
+ `specflo requirement add --text "…" --acceptance "…" [--from D-NN]`
62
+ (add `--supersedes REQ-NN` when it replaces an earlier one). Don't batch —
63
+ capture as they happen. Keep the prose sections (Objective, Boundaries In/Out,
64
+ Open questions, Canonical refs) current by editing `spec.md` directly.
65
+ 5. **Hold the scope boundary.** Fill **Boundaries** — In scope and Out of scope —
66
+ carrying the brainstorm's Out of scope / Deferred forward. Both lists must be
67
+ non-empty.
68
+ 6. **No stale specifics.** The spec is behavioral and durable: no file paths or
69
+ code snippets that churn. One exception — a small prototype snippet that
70
+ encodes a decision more precisely than prose can (a schema or type shape).
71
+ 7. **Self-review.** Re-read the spec with fresh eyes: placeholder scan; internal
72
+ consistency (do requirements contradict?); two-way ambiguity (pick one reading
73
+ and make it explicit); scope (one plan, or should this decompose?).
74
+ 8. **Gate + validate.** Ask the user an explicit "ready?". On yes, run
75
+ `specflo validate spec`; fix any reported gaps inline and re-run until it
76
+ passes.
77
+ 9. **Hand off — pause at the phase boundary.** Surface the end of the phase as one
78
+ clear beat, and **do not auto-advance**: the spec is complete and validated, the
79
+ **checkpoint is saved** (the project's `checkpoint.md`; resume any time with
80
+ `specflo checkpoint`), so this is a **safe place to clear context**. The plan
81
+ phase is next (it synthesizes from `spec.md` and cites `REQ-NN` — no
82
+ re-interview). Then **wait** — `specflo advance` is the user's to call; don't
83
+ change the phase yourself or start the plan.
84
+
85
+ ## Anti-sycophancy
86
+
87
+ Take a position on every requirement and state what evidence would change it.
88
+ Avoid filler validation: "That's a great requirement," "That could work," "There
89
+ are many ways to think about this." Challenge the strongest version of the
90
+ user's intent, not a strawman. If a requirement isn't testable, say so and
91
+ reframe it.
92
+
93
+ ## Common rationalizations
94
+
95
+ | Rationalization | Reality |
96
+ |---|---|
97
+ | "The brainstorm is enough." | The brainstorm captures *decisions*, not *testable requirements*. The spec adds acceptance criteria a verifier can check. |
98
+ | "A tech-stack decision isn't a requirement." | It is — spec the *behaviors* it exists to enable. One decision usually fans out into several testable requirements, each `--from` it. |
99
+ | "I'll write the acceptance after I code it." | That's a test report, not a specification. Decide pass/fail *before* building. |
100
+ | "This requirement is obviously testable." | If you can't state the pass/fail check in one line, it isn't. Write the line. |
101
+ | "I'll re-ask the user to be safe." | Re-interviewing is not synthesis. Read `brainstorm.md`; only ask about genuine gaps. |
102
+ | "Boundaries are obvious." | Unwritten scope is where plans balloon. Write In and Out explicitly. |
103
+
104
+ ## Red flags (stop and correct)
105
+
106
+ - A requirement has no pass/fail acceptance criterion.
107
+ - You wrote a requirement into chat prose instead of `specflo requirement add`.
108
+ - You re-interviewed the user on something the brainstorm already decided.
109
+ - You put file paths or churn-prone code snippets into `spec.md`.
110
+ - You moved toward the plan without an explicit user "ready?" and a passing
111
+ `specflo validate spec`.
112
+ - You built or scaffolded something during the spec phase.
113
+
114
+ ## Verification checklist
115
+
116
+ - [ ] `specflo spec start` was run; `spec.md` exists for the active project.
117
+ - [ ] Every requirement is in the Requirements section via `specflo requirement add`.
118
+ - [ ] Every requirement has a pass/fail **Acceptance** criterion.
119
+ - [ ] **Boundaries** — In scope and Out of scope — are both filled in.
120
+ - [ ] **Open questions** is present (may say "none").
121
+ - [ ] `specflo validate spec` passes.
122
+ - [ ] The user explicitly approved readiness before handoff.
123
+ - [ ] At hand-off, the checkpoint-saved phase-end beat was surfaced and `specflo advance` was left to the user.
124
+ - [ ] No code or scaffolding was produced.
specflo/brainstorm.py ADDED
@@ -0,0 +1,179 @@
1
+ """The brainstorm artifact and its operations.
2
+
3
+ Each project gets a single ``brainstorm.md`` next to ``project.md``. The CLI owns
4
+ the structured, stateful parts of this file — scaffolding, the append-only
5
+ Decisions section (stable ``D-NN`` IDs, supersede-as-event), and read-only
6
+ linting. The brainstorm skill writes the prose sections (Current understanding,
7
+ Out of scope, Open questions, Canonical refs) directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from .config import SpecfloConfig
18
+ from .errors import SpecfloError
19
+ from . import markdown
20
+ from .projects import load_project, project_dir
21
+
22
+ BRAINSTORM_FILENAME = "brainstorm.md"
23
+
24
+ _DECISION_ID_RE = re.compile(r"^### (D-\d+) —", re.MULTILINE)
25
+
26
+ _TEMPLATE = """\
27
+ ---
28
+ project: {slug}
29
+ phase: brainstorm
30
+ status: draft
31
+ created: {today}
32
+ updated: {today}
33
+ ---
34
+
35
+ # Brainstorm: {name}
36
+
37
+ ## Current understanding
38
+ <!-- rewritten as it converges; the synthesis the spec phase reads -->
39
+
40
+ ## Research
41
+ <!-- findings + surprises from the research subagent (optional); links go to Canonical refs -->
42
+
43
+ ## Decisions
44
+ <!-- append-only; managed by `specflo decision add`. Stable IDs D-NN. -->
45
+
46
+ ## Out of scope / Deferred
47
+ <!-- required, must be non-empty before validate passes -->
48
+
49
+ ## Open questions
50
+ <!-- required section (may say "none") -->
51
+
52
+ ## Canonical refs
53
+ <!-- full paths the brainstorm leaned on -->
54
+ """
55
+
56
+
57
+ @dataclass
58
+ class Decision:
59
+ id: str
60
+ text: str
61
+ rationale: str
62
+ supersedes: str | None
63
+ status: str
64
+
65
+
66
+ def brainstorm_path(root: Path, cfg: SpecfloConfig, slug: str) -> Path:
67
+ return project_dir(root, cfg, slug) / BRAINSTORM_FILENAME
68
+
69
+
70
+ def start_brainstorm(
71
+ root: Path, cfg: SpecfloConfig, slug: str, today: str | None = None
72
+ ) -> tuple[Path, bool]:
73
+ """Create the brainstorm artifact, or locate an existing one.
74
+
75
+ Returns ``(path, created)``; ``created`` is False if the file already existed
76
+ (resume-friendly — never clobbers).
77
+ """
78
+ project = load_project(root, cfg, slug) # raises SpecfloError if missing
79
+ path = brainstorm_path(root, cfg, slug)
80
+ if path.exists():
81
+ return path, False
82
+ today = today or datetime.date.today().isoformat()
83
+ path.write_text(
84
+ _TEMPLATE.format(slug=project.slug, name=project.name, today=today)
85
+ )
86
+ return path, True
87
+
88
+
89
+ def add_decision(
90
+ root: Path,
91
+ cfg: SpecfloConfig,
92
+ slug: str,
93
+ text: str,
94
+ rationale: str | None = None,
95
+ supersedes: str | None = None,
96
+ today: str | None = None,
97
+ ) -> Decision:
98
+ """Append a decision to the Decisions section and return it.
99
+
100
+ Assigns the next ``D-NN`` id. If ``supersedes`` is given, the named decision
101
+ is marked superseded (kept in place) and linked from the new entry.
102
+ """
103
+ path = brainstorm_path(root, cfg, slug)
104
+ if not path.is_file():
105
+ raise SpecfloError("No brainstorm yet. Run `specflo brainstorm start` first.")
106
+ doc = path.read_text()
107
+ if "## Decisions" not in doc:
108
+ raise SpecfloError("Malformed brainstorm.md: no '## Decisions' section.")
109
+
110
+ if supersedes is not None and not re.search(
111
+ rf"^### {re.escape(supersedes)} —", doc, re.MULTILINE
112
+ ):
113
+ raise SpecfloError(f"No decision {supersedes} to supersede.")
114
+
115
+ new_id = markdown.next_id(doc, "D-")
116
+ rationale_text = rationale if rationale else "—"
117
+
118
+ if supersedes is not None:
119
+ doc = markdown.mark_superseded(doc, supersedes, new_id)
120
+
121
+ entry_lines = [f"### {new_id} — {text}", f"- Rationale: {rationale_text}"]
122
+ if supersedes is not None:
123
+ entry_lines.append(f"- Supersedes: {supersedes}")
124
+ entry_lines.append("- Status: active")
125
+ entry = "\n".join(entry_lines) + "\n"
126
+
127
+ doc = markdown.append_to_section(doc, "## Decisions", entry)
128
+ doc = markdown.bump_updated(doc, today)
129
+ path.write_text(doc)
130
+ return Decision(
131
+ id=new_id,
132
+ text=text,
133
+ rationale=rationale_text,
134
+ supersedes=supersedes,
135
+ status="active",
136
+ )
137
+
138
+
139
+ def complete_brainstorm(
140
+ root: Path, cfg: SpecfloConfig, slug: str, today: str | None = None
141
+ ) -> None:
142
+ """Mark the brainstorm complete (``status: draft → complete``); bump ``updated``.
143
+
144
+ Called when leaving the brainstorm phase (by `specflo advance`). Raises
145
+ ``SpecfloError`` if the artifact is missing.
146
+ """
147
+ path = brainstorm_path(root, cfg, slug)
148
+ if not path.is_file():
149
+ raise SpecfloError("No brainstorm yet. Run `specflo brainstorm start` first.")
150
+ doc = path.read_text()
151
+ # Frontmatter `status:` only — the leading-`-` decision `- Status:` lines and
152
+ # the count=1 (frontmatter comes first) keep this from touching entries.
153
+ doc = re.sub(r"(?m)^status:.*$", "status: complete", doc, count=1)
154
+ doc = markdown.bump_updated(doc, today)
155
+ path.write_text(doc)
156
+
157
+
158
+ def validate_brainstorm(root: Path, cfg: SpecfloConfig, slug: str) -> list[str]:
159
+ """Return a list of lint issues (empty == ready). Read-only."""
160
+ path = brainstorm_path(root, cfg, slug)
161
+ if not path.is_file():
162
+ return ["brainstorm.md not found — run `specflo brainstorm start`."]
163
+ doc = path.read_text()
164
+ body = markdown.strip_comments(doc)
165
+ issues = markdown.placeholder_issues(body)
166
+
167
+ if not _DECISION_ID_RE.search(doc):
168
+ issues.append("no decisions captured (need at least one).")
169
+
170
+ out_of_scope = markdown.section_body(doc, "## Out of scope / Deferred")
171
+ if out_of_scope is None:
172
+ issues.append("missing 'Out of scope / Deferred' section.")
173
+ elif not markdown.strip_comments(out_of_scope).strip():
174
+ issues.append("'Out of scope / Deferred' section is empty.")
175
+
176
+ if markdown.section_body(doc, "## Open questions") is None:
177
+ issues.append("missing 'Open questions' section.")
178
+
179
+ return issues
specflo/checkpoint.py ADDED
@@ -0,0 +1,183 @@
1
+ """The `checkpoint` resume prompt — derived, agent-facing "resume here" state.
2
+
3
+ After every state-mutating command specflo rewrites a per-project
4
+ ``checkpoint.md``: a short, derived prompt telling a freshly-cleared agent which
5
+ phase we're in, which files to read first, and the concrete next action. It is
6
+ fully derived from project state (phase + existing artifacts + the workflow's
7
+ next step), so it can be written automatically and is always current.
8
+
9
+ Mirrors ``guide.py``: pure derivation (``build_checkpoint``) + a renderer
10
+ (``render_checkpoint``) + a thin writer (``write_checkpoint``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ from pathlib import Path
17
+
18
+ from . import plan as plan_module, validators, workflow
19
+ from .brainstorm import BRAINSTORM_FILENAME
20
+ from .config import SpecfloConfig, display_path
21
+ from .projects import (
22
+ COMPLETE_STATUS,
23
+ PROJECT_FILENAME,
24
+ SHELVED_STATUS,
25
+ Project,
26
+ project_dir,
27
+ )
28
+ from .spec import SPEC_FILENAME
29
+
30
+ CHECKPOINT_FILENAME = "checkpoint.md"
31
+
32
+ # Phase artifacts in pipeline order. ``checkpoint.md`` lists only the ones that
33
+ # actually exist, so there are no dangling references and the list grows on its
34
+ # own as later artifacts (plan.md, ...) land.
35
+ _ARTIFACT_ORDER: list[str] = [BRAINSTORM_FILENAME, SPEC_FILENAME, "plan.md"]
36
+
37
+
38
+ def checkpoint_path(root: Path, cfg: SpecfloConfig, slug: str) -> Path:
39
+ return project_dir(root, cfg, slug) / CHECKPOINT_FILENAME
40
+
41
+
42
+ def build_checkpoint(
43
+ root: Path,
44
+ project: Project,
45
+ cfg: SpecfloConfig | None = None,
46
+ today: str | None = None,
47
+ ) -> dict:
48
+ """Derive the resume-prompt payload for ``project`` from current state.
49
+
50
+ Read-only: inspects which artifacts exist on disk but mutates nothing.
51
+
52
+ ``cfg`` is threaded from the write path so brainstorm/spec/plan can derive
53
+ honest doneness — running the phase's real validator inline (REQ-01/03).
54
+ Without it (a bare ``build_checkpoint`` call), the static work hint stands.
55
+ """
56
+ directory = project.path
57
+ read_first = [display_path(directory / PROJECT_FILENAME, root, posix=True)]
58
+ for filename in _ARTIFACT_ORDER:
59
+ if (directory / filename).is_file():
60
+ read_first.append(display_path(directory / filename, root, posix=True))
61
+ shelved = project.status == SHELVED_STATUS
62
+ plan_file = directory / plan_module.PLAN_FILENAME
63
+ prog = None
64
+ plan_doc = None
65
+ milestone = None
66
+ boundary = None
67
+ # A shelved project's do_next ignores progress, so skip the plan-file read.
68
+ if not shelved and project.phase in ("plan", "execute") and plan_file.is_file():
69
+ plan_doc = plan_file.read_text()
70
+ prog = plan_module.progress_from_doc(plan_doc)
71
+ # The current milestone (None on a milestone-free or all-complete plan),
72
+ # named in the resume block at plan/execute so a resumed agent knows which
73
+ # slice the plan is on (REQ-15). Dormant without milestones (REQ-04).
74
+ milestone = plan_module.current_milestone_from_doc(plan_doc)
75
+ # The soft milestone-boundary verify beat (None off a boundary): the
76
+ # just-completed milestone's Exit checklist to verify before proceeding
77
+ # (REQ-14). Surfaced in the resume block; never a hard stop.
78
+ boundary = plan_module.milestone_boundary_from_doc(plan_doc)
79
+ if shelved:
80
+ # Paused: don't direct to the phase's work step — resume (or start new),
81
+ # while the recorded phase below is preserved so resume returns to it.
82
+ do_next = workflow.next_step(project.phase, shelved=True)
83
+ elif project.phase == "execute":
84
+ do_next = workflow.next_step(
85
+ "execute", progress=prog, complete=project.status == COMPLETE_STATUS
86
+ )
87
+ # Stuck on a superseded dependency: surface the same targeted rewire
88
+ # remediation as `task show`/`status`, replacing the generic hint.
89
+ if project.status != COMPLETE_STATUS and plan_doc is not None:
90
+ stuck = plan_module.stuck_next_step_from_doc(plan_doc)
91
+ if stuck:
92
+ do_next = stuck
93
+ else:
94
+ # Derived doneness (REQ-01/03): run the phase's real validator inline
95
+ # (no memoization) — a passing validator flips the hint to offer-advance,
96
+ # a failing or missing artifact reads as work-in-progress. cfg is threaded
97
+ # from the write path; without it the static work hint stands.
98
+ validates = False
99
+ if cfg is not None:
100
+ validator = validators.VALIDATORS.get(project.phase)
101
+ if validator is not None:
102
+ validates = not validator(root, cfg, project.slug)
103
+ do_next = workflow.next_step(project.phase, validates=validates)
104
+ # A plan that doesn't yet validate still names its next task; once it
105
+ # validates the offer-advance hint stands alone.
106
+ if project.phase == "plan" and not validates and prog is not None:
107
+ if prog["next_actionable"]:
108
+ do_next += " (next task: " + ", ".join(prog["next_actionable"]) + ")"
109
+ elif prog["all_done"]:
110
+ do_next += " (all tasks done)"
111
+ return {
112
+ "project": project.slug,
113
+ "phase": project.phase,
114
+ "status": project.status,
115
+ "shelved_reason": project.shelved_reason,
116
+ "generated": today or datetime.date.today().isoformat(),
117
+ "read_first": read_first,
118
+ "do_next": do_next,
119
+ "milestone": milestone,
120
+ "boundary": boundary,
121
+ "path": display_path(directory / CHECKPOINT_FILENAME, root, posix=True),
122
+ }
123
+
124
+
125
+ def render_checkpoint(payload: dict) -> str:
126
+ """Render the payload to the markdown written to ``checkpoint.md``."""
127
+ shelved = payload.get("status") == SHELVED_STATUS
128
+ subtitle = f"_phase: {payload['phase']}"
129
+ if shelved:
130
+ subtitle += " (shelved)"
131
+ subtitle += f" | generated {payload['generated']}_"
132
+ lines = [
133
+ f"# Checkpoint - {payload['project']}",
134
+ subtitle,
135
+ "",
136
+ ]
137
+ if shelved and payload.get("shelved_reason"):
138
+ lines += [f"**Shelved:** {payload['shelved_reason']}", ""]
139
+ lines += [
140
+ "## Read first",
141
+ *(f"- {path}" for path in payload["read_first"]),
142
+ "",
143
+ "## Do next",
144
+ payload["do_next"],
145
+ ]
146
+ # Name the current milestone in the resume block so a resumed agent knows
147
+ # which slice the plan is on (REQ-15); absent on a milestone-free plan.
148
+ milestone = payload.get("milestone")
149
+ if milestone:
150
+ lines.append(
151
+ f"Current milestone: {milestone['id']} {milestone['title']} "
152
+ f"— {milestone['done']}/{milestone['total']} done."
153
+ )
154
+ # The soft milestone-boundary verify beat: the just-completed milestone's Exit
155
+ # checklist for a user-gated proceed (REQ-14). Absent off a boundary.
156
+ boundary = payload.get("boundary")
157
+ if boundary:
158
+ lines += ["", "## Milestone boundary", *plan_module.boundary_beat_lines(boundary)]
159
+ lines += [
160
+ "",
161
+ "## Resume",
162
+ "- `specflo status` - confirm phase/step",
163
+ "- `specflo checkpoint` - reprint this prompt",
164
+ "",
165
+ ]
166
+ return "\n".join(lines)
167
+
168
+
169
+ def write_checkpoint(
170
+ root: Path,
171
+ project: Project,
172
+ cfg: SpecfloConfig | None = None,
173
+ today: str | None = None,
174
+ ) -> Path:
175
+ """Render the checkpoint for ``project`` and write ``checkpoint.md``.
176
+
177
+ ``cfg`` is forwarded to :func:`build_checkpoint` so the written checkpoint
178
+ reflects derived doneness for brainstorm/spec/plan (REQ-01).
179
+ """
180
+ payload = build_checkpoint(root, project, cfg=cfg, today=today)
181
+ path = project.path / CHECKPOINT_FILENAME
182
+ path.write_text(render_checkpoint(payload))
183
+ return path