Repository navigation
Releases: tranquocthong/spec-flow
Release list
v0.15.1 — Derived state mirrors are git-ignored
Changed
- The two root mirrors are git-ignored, and
/sf:doctorwarns when a project still tracks them..spec-flow/trace.jsonand.spec-flow/STATE.mdare derived "active feature" mirrors, rewritten by everytrace-buildandstate-updateand shared by every concurrent session, but nothing kept them out of git. In a downstream project that ran features in parallel they were tracked: every build dirtied the tree, and two agents were seen resolving it by hand, each restoring the mirror to a different feature (one to the feature it was building, one to the last committed copy, which belonged to a third). A mirror had also been committed onmainholding another feature's state.init-projectnow appends/.spec-flow/trace.jsonand/.spec-flow/STATE.mdto.gitignore(idempotent; the durablespecs/<feature>/copies stay tracked), and the newstate-mirror-trackeddoctor check warns, with the exactgit rm --cachedfix, when the mirrors are tracked or sit untracked but not ignored. It never touches git itself. An existing project needs thegit rm --cachedonce; the session re-anchor hook already tolerates a missingSTATE.md, so a fresh clone simply has no mirror until the firststate-update. - The new tests run against a git that reads no global config: a developer's global gitignore that already covers
.spec-flow/(the maintainer's does) made every ignore/track assertion depend on the machine.
v0.15.0 — Worktree cleanup is named at close
Added
- A ship now names the git worktrees it leaves behind. A parallel SD runs in its own linked
git worktreeso two sessions stop sharingSTATE.mdandtasks.json, but a worktree is a full checkout and nothing removed it when the work closed, so they accumulated until the disk filled.state-update --shippednow returnsworktreesand aWORKTREE CLEANUPwarning (path, size, branch, and the exactgit worktree removecommand, run from the main worktree);/sf:phase,/sf:bugand/sf:changetell the agent to end the close-out report with it. The new read-onlyworktree-infogives the same list on demand, covering the current directory and everyconfig.reposroot. It never removes anything: the PR is usually not merged at ship time, and a worktree cannot be removed from inside itself.
v0.14.0
Changed
- The pre-ship code review is now off by default. It was documented as optional, but the default
askleft the decision to the orchestrating agent, which in practice ran it without asking. Nowconfig.phase.codeReviewabsent,nullor unreadable resolves tooff:review-scopereturnsgate: off, and/sf:phasestep 3b and/sf:changestep 6 skip it without offering.askandalwaysstill work and are the opt-in;asknow has to be a realAskUserQuestionprompt, and anything but an explicit yes (no answer, no prompt shown, auto-mode) counts as no.init-projectseeds and back-fillsoff, and/sf:phasenow says in plain words that nothing butgate: ask|alwaysauthorises a review. Projects whose config already saysask(an earlier back-fill wrote it) keep asking until the key is set tooff.
Added
expect_status:onhttpsetup steps. A setup step aborted the whole test on any status >= 400, so a case that has to fail on purpose before its assertion (a wrong OTP submitted 2-4 times to reach a lockout, each answered 401) could never get past setup, and a capture on such a step never ran. A step can now declare the status it is meant to get: an int, a list ([400, 401]) orany, nested inhttp:or beside it. Undeclared steps behave as before. A declared step still fails on any other status, so it cannot go green on a 200 or a 500, andanyaccepts every real response but not "no response".lint-checklist.shrejects a malformed value before the run, using the same parser as the runner.
v0.13.0 — Coding rules are enforced, not suggested
Added
- Coding rules in
project-author.mdare now enforced, not suggested. A team's rules reached the code only as a paragraph the executor was told to "read if present". Nothing checked them, and the pre-ship reviewer was even told "House style is not a finding", so violations reached the team's own reviewers and the PR was rejected there. Four gaps, four fixes:verify.rules, a diff-scoped rule gate.forbiddenPatternsscans the whole tree on every task, so adding a rule to a project whose old code already breaks it failed every task, and in practice nobody added one.verify-codenow has acode-rulescheck that fails only on lines this branch added or changed (merge-base withbranching.base, uncommitted and untracked included) and reports the old violations as apreexistingcount. Rules can be scoped byglob, and besidesforbidthey can say "a file with A must also have B" (when+require), for rules like "@Scheduledneeds@SchedulerLock". A broken rule is skipped with a warning and never fails the gate.forbiddenPatternsis unchanged.- The executor answers every rule.
/sf:phasepastes the## Code Rulesbullets verbatim into each executor prompt, and the executor must return apass | n/a | violatedrow per bullet. A missing table or an openviolatedrow is sent back, like missing TDD evidence. - The reviewer checks every rule, and a violation blocks. The "house style is not a finding" instruction is gone. Each
## Code Rulesbullet is a checklist item, and a violation is aproject-rulefinding:review-collectraises it to at leastmediumand treats it as blocking, withreview-acceptas the override. Taste outside## Code Rulesstayslow. /sf:doctorvalidatesverify.rules, and new projects get an empty## Code Rulessection inproject-author.md(existing files are not touched).
- Backlog is now a record type, not a folder of free-form notes.
.spec-flow/backlog/was a convention nothing read: no order, no status, and/sf:statusreported "nothing pending" while deferred work sat there.backlog-new --title --priority high|medium|low [--desc] [--feature] [--epic]writesNNN-bl-<slug>.md(idbl-NNN, numbered from the highest existing prefix, likebug-new). Priority is required and has no silent default, since a defaulted priority carries no information.backlog-listsorts high > medium > low > unset, then oldest first, and filters by--status(defaultopen),--epic,--feature.backlog-setchanges priority or status (open|done|dropped) in place, leaving the rest of the file byte-for-byte.- Existing hand-written backlog files keep working: without the record marker they are listed as
legacywith priorityunset, and a list never rewrites them./sf:doctorwarns about them (and about records with no priority) with thebacklog-setcommand that fixes each. - After a ship, the "nothing pending" next step now names the top open backlog item, and
/sf:statusshows the open count and the top three. With no open backlog both outputs are unchanged.
- Existing hand-written backlog files keep working: without the record marker they are listed as
- An epic is now a directory, not a single file. An epic spans many specs and many months, and the documents that belong to it belong to no single spec: the per-phase SRS, cross-phase decisions, environment state (SIT, UAT, handoff), gotchas, e2e mocks.
epic-newused to write one.spec-flow/epics/<slug>.mdwith a hand-writtenstatus: pendingthat was wrong after the first phase, and sub-features were grouped only by a name prefix. In practice teams skipped it and built their ownepics/folder outside.spec-flow/.epic-newcreates.spec-flow/epics/<slug>/withEPIC.mdand conventionalsrs/ decisions/ state/ assets/subdirectories. The subdirectories are a convention; nothing is enforced.epic-attach --epic --featureadds a feature to an epic and writesspecs/<feature>/EPICas the back link. A feature belongs to at most one epic (EPIC_CONFLICTotherwise)./sf:ingest <srs> --epic <slug>attaches as it ingests, creating the epic if needed, which fits the common case: phases ingested one at a time over months rather than split once.epic-listand the newepic-showcompute progress from each sub-feature's real tasks and ship record (done,total,shipped) instead of trusting a written status./sf:statusshows the epic's progress when the active feature belongs to one.- SD, tasks, checklist and verification stay in
specs/<feature>/; the epic directory holds only cross-phase material. - Single-file epics still list and show (
legacy: true);epic-attachrefuses them with the one-line conversion command, and nothing is migrated automatically. /sf:doctorwarns (never fails) about single-file epics, links that disagree in either direction, and files in an epic directory whose names look like secrets (priv_key,secret,.pem,.env…) and that git does not ignore, since.spec-flow/is usually committed.
v0.12.1
Fixed
bug-newnumbered its next id by counting files, not by reading the highest existing id. A count collides with a real id whenever the working tree doesn't have every file 1..N locally -- e.g. bug-004 already created on an unmerged branch/MR, while the current branch only sees 3 files and recomputes 004 for its own new bug. It now scans the NNN-bug- prefixes already on disk and assigns max + 1. Regression test added in test/flow-tools.test.cjs.
v0.12.0 — Pre-ship code review
Added
-
An optional code-review gate in the ship step. The agents that wrote a feature cannot review it — they are inside their own reasoning.
/sf:phaseclose-out now offers one independent pass over the finished diff, in a context that never wrote a line of it, immediately before the commit.config.phase.codeReviewdecides whether it runs. Three states, because the default is a question rather than a behaviour:ask(default —/sf:phaseasks yes/no once per ship),always(no prompt),off(skip entirely).init-projectback-fills the key into existing configs without changing behaviour, since absent already read asask.review-scopecomputes the scope deterministically: the base branch,<base>...HEAD(null when branching is off, when you are on base, or when the base ref will not resolve — never a bogus range), the files the executors actually wrote fromfile-links.json, and the repo roots on a multi-repo feature, where those files are the only reliable scope. It also reports a prior verdict and whether HEAD has moved past it, so an unchanged tip re-uses its review instead of paying for a second pass.- The reviewer is a sub-agent, read-only, pinned by
config.models.codeReviewer(defaultsonnet). It reports — it never fixes. Fixing is a decision the user makes afterwards, and a post-review fix re-enters the verification gates. - The gate is built against its real failure mode, which is a noisy reviewer rather than a lazy one. A model handed a bare diff cannot tell a defect from a deliberate decision, so it reports both at the same severity, and after two such reviews the user declines forever and the gate is dead. So
review-scopealso returns acontextPack— the SD,CONTEXT.md, the checklist,VERIFICATION.md(including gaps already known and accepted),project-author.mdand the trace — and the step's standing rules make the reviewer check every critical/high against the spec before writing it, cap taste atlow, and name the inputs and wrong result that a blocking finding produces. Acritical/highmust carrycheckedAgainst(the FR or TC it was tested against);review-collectreturns any that do not asuncheckedBlocking, and/sf:phasesends those back for a re-rate instead of halting a ship on an unchecked claim. review-collectturns the findings intospecs/<feature>/CODE-REVIEW.mdand a deterministic verdict:clean,advisory, orblockingon any critical/high. Blocking halts the ship and hands the user three choices — fix now, ship anyway, or abort.review-accept --noterecords a human's "ship anyway" on disk. Without it the override would live only in a transcript, and the next session would read an unresolved blocker./sf:statussurfaces an unaccepted blocking verdict in its next step.- Codex: the
code-reviewskill is a Claude Code built-in and is not part of the package. The Codex runtime contract tells the host to perform the review itself against the same output contract when it has no equivalent, and keeps the gate's shape either way — a review that was performed but never collected did not happen.
-
/sf:doctornow catches a stale local plugin install (plugin-source-sync). A plugin installed from a local directory marketplace does not run from the source repo: Claude Code copies it into~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/and runs that. The cache path is keyed by version, so while the version string holds still — exactly what a careful maintainer does between releases — edits to the source never reach the running plugin. The failure is silent and total: a new engine command answersUNKNOWN_COMMAND, an edited command doc is simply the old one. Nothing in doctor said so, and finding it by hand costs a session. The check compares the runtime surface (bin,lib,commands,agents,skills,templates,hooks) by content hash, names the differing files, explains why/plugin updatewill not fix it, and hands over a copy-pasteablersync. It is silent for a normal github install, and for a contributor running the engine straight from a checkout.
Notes
- The gate is optional; the hard gates are unchanged.
verify-code, the regression sweep, andVERIFICATION.md: status: passedstill guard the ship on their own, and declining the review is a silent, recorded-nowhere no-op rather than a clean verdict.
v0.11.1 — Codex support
Adds the generated Spec Flow plugin for Codex while preserving the shared project artifacts and Claude Code workflow. Includes reproducible packaging, validation, compatibility tests, and the Codex installation guide.
v0.11.0 — a green manual-test run can no longer mean nothing executed
The manual-test gate could report a green run having executed nothing, and this release makes that impossible to say. Found by auditing a downstream project's 842 test cases across 33 features, then reproduced against the runner in two lines of YAML. The plugin's own backlog note (run-checklist-no-verify-false-green.md, filed 2026-09-10) described one symptom of it; this is the root cause and the fix.
The defect
runner.py decides the outcome of a test with if errs: FAIL else: PASS. PASS means "no assertion reported an error" — not "an assertion passed." A test with nothing to send and nothing to assert produces no errors, so it passes without executing anything:
suites:
- name: probe
tests:
- {id: TC-001, tags: [live-e2e, regression]}
- {id: TC-002, tags: [no-verify, regression]}run-checklist.sh probe.yaml --tag regression --json
TC-001 GET
✓ PASS status=0 <-- no request ever left the machine
total: 2 passed: 2 failed: 0
{"passed": ["TC-001", "TC-002"], "failed": []} exit 0
verify-collect then believed that count and wrote status: passed with - TC-001: verified under it. status=0 was the only tell, and it reads like a status code.
The 0.10.x lint already refuses a decorative expect: (a bare {n: 3} cannot fail a test, so it is not an assertion), but its own message offers the way around itself — "OR tag the test no-verify / live-e2e". Those tags mark a placeholder for a hand-run step, and in practice they are written alongside regression, so --tag regression selected them and counted them green. The carve-out in the lint was the entrance to the false green.
The fix
runner.pynow separates executed from passed. A test with norequest, noverify, no assertingexpectkey and no executingsetupstep is reported under a newnotVerifiedgroup — named in the console summary, and emitted as"notVerified": [{id, reason}]in the--jsonline. It is never counted as passed. Exit code is unchanged, so nothing that shells out to the runner breaks.verify-collectrefuses to record evidence it does not have. An id undernotVerifiednever becomes a- TC-xxx: verifiedtruth. Its presence holds the status atincomplete— deliberately notverified-adhoc, whichVERIFIED_STATUS_REaccepts as shippable because it asserts a human verified out of loop. Nobody has, yet.incompletematches no gate, so G3 stays shut until someone runs those cases, writes down what they saw, and setsverified-adhocthemselves. The generated file names each owed case under## Not verifiedwith the reason.- The runner no longer synthesises a GET for a test with no
requestblock. It used to send an empty request tobase_urland printGET / status=0, which reads as a real HTTP check that passed — one downstream VERIFICATION.md had already recorded the confusion (for TC-013 the runner printed GET / status=404 / PASS). Against a live environment that stray call could also touch a real endpoint. Such a test is now labelled— asserted by setup/verify, and only itsexpect.pollstill runs. - Lint gained a negative-path coverage warning. The per-test rule asks whether a test asserts anything; it never asked whether the checklist ever exercises a rejection. In the audited project 14 of 33 features had zero test cases asserting
status >= 400— every error code and guard clause unexercised while every gate read green. A warning, not a failure: it is a signal about the SD's §13.2 test cases, and existing checklists must stay runnable.
A correction worth recording, because the first two passes of this audit were wrong. An initial sweep called 228 of 842 test cases empty and flagged seven features as false-green. Both numbers were inflated. setup: steps carrying exec:/sql: are assertions — run_steps aborts on the first failure and the caller turns that into a FAIL, so exec: ... exit 1 asserts perfectly well without an expect block, and one feature's 26 test cases were built entirely that way. And most flagged VERIFICATION files carried real hand-written evidence (verified-live — 400 auth.invalid.otp, attemptsRemaining 5→4) that a status:-line check could not see. After both corrections: zero genuine false-greens on disk. The mechanism is real and reproducible; in that project humans had caught every instance by hand. What this release removes is the need to.
Task engine: MCP surface removed, CLI only
.mcp.json is deleted. The plugin declares no MCP server — every task operation goes through the bundled CLI (bin/flow-tools.cjs task-add / task-get / task-list / task-set-status / task-next, and bin/task-master for the AI ops). The native engine was already the only binding and the CLI twins have shipped since 0.8.x; what is gone is a second way in that could fail to connect, bind a stale surface, or shadow the engine from a project .mcp.json.
commands/init.mdno longer prefers aninitialize_projectMCP tool — the CLI bin is the only path.agents/hybrid-executor.mdnamesflow-tools.cjs task-getinstead ofmcp__task-master-ai__get_task./sf:doctordep-locknow verifies the CLI entry points exist and warns if an.mcp.jsonreappears at the plugin root.mcp-shadownow warns about any project-leveltask-master-aientry, including the old "native" shape: with no server shipped, such an entry binds a separate implementation writing the same.taskmaster/files.
bin/mcp-server.js and lib/mcp-server.cjs are retained but no longer referenced by any binding — the rollback and cutover machinery still exercises them on temp files. Removing them is a separate cleanup.
Full suite green: 910 engine tests (node --test test/*.test.cjs, +3) and 80 checklist-runner tests (python3 -m unittest checklist_lib.tests.test_checklist_lib, +8).
v0.10.1
Four bugs surfaced by running /sf:ingest against a real SRS in a downstream project, all in the Pass-1 deterministic harvest and the traceability engine. None block a fix directly in code — bin/flow-tools.cjs, lib/core.cjs, lib/trace.cjs.
- Pass-1 silently truncated harvested FR/TC/NFR cells at a hard 200/220-char cap. Measured on a real SRS: every requirement sentence past ~220 chars got cut mid-word with no
…and no signal — several lost only the closing period, reading as a complete sentence. The cap is now a generous backstop (2000 chars) meant only for a genuinely pathological row; ordinary harvested text (verified up to 357 chars) now survives whole. A cell that still hits the backstop is named indata.warnings(TRUNCATED: ...) instead of disappearing silently. - §5.2 NFR harvest read columns positionally, not by header. A heading-harvested NFR table assumed the bare
Requirement | Targetshape (r[0]/r[1]); a richerID | Category | Requirement | Targettable — the SD's own §5.2 convention — got its ID column read as the requirement text, its real Category dumped into Target next to the actual target value, and every row force-labeledPerf/Secregardless of its real category (Resilience, Regression, ...). Columns now resolve by header keyword (English + Vietnamese lang packs), falling back to the old positional read only for a genuinely unlabeled 2-3 col table. trace-build's §10.4 header-drift check false-positived on a repurposed section. A### 10.4section kept its number but retitled to unrelated content (e.g. an OUTBOX vs DIRECT route comparison — sd-template.md explicitly allows deleting/repurposing §10.4 when a feature has no state machine) was reported as a "state table header mismatch" even though the table was never meant to be a state table. The check now also requires the heading title to still carry the word "state" before warning.- Switching the active feature's global
trace.json/STATE.mdmirror was silent.trace-buildandstate-updatealready computedswitchedFrombut returned it as inert JSON metadata with no severity — easy for a caller that only checksdata.warningsto miss entirely. Both now push anACTIVE FEATURE SWITCHEDwarning when the feature being switched away from has no ship record (still-open work); switching away from an already-shipped feature stays silent (expected churn).
All four fixed with regression tests (8 added to test/core.test.cjs + test/flow-tools.test.cjs); full suite 907/907.
v0.9.0
Nineteen rough edges from one real dogfooding session (an eid-gateway feature and a 26-FR/47-TC phase), grouped by what they actually cost. Group 1 is false-green / lost-evidence: a command reports success, or asserts nothing, while the thing it claims to check never happened. Group 2 is noise that wastes time without corrupting the result. Both groups share one theme this release keeps hitting: an engine that silently drops the SECOND (or third, or fourth) matching thing — a table, a repo, an FR, a stderr stream — instead of merging or naming what it dropped.
Group 1 — false-green / lost evidence
verify-collectnever wroteVERIFICATION.md. It computed{status, passed, failed, truths}and returned them as JSON only —commands/manual-test.mdstep 4 says "This writes VERIFICATION.md," but nothing did.status-report,doctor's verify-integrity check, andtask-baselineall read that file to decide a feature is verified; a full 68/68 pass reportedstatus:"passed"on stdout while the feature stayed "not verified" forever and the ship gate never opened. It now actually writes the file, in thestatus: passed|failed+- TC-xxx: verifiedshape those readers already expect. Also fixed in the same command:--resultsonly accepted a file path, but the doc's own worked example passes the JSON result line inline — now accepts either.--featureis required (no inference from the shared active-feature mirror — the same write-safety ruletrace-linkalready enforces).- A dict
expect:on a SQLverify:/setup:/teardown:step was never asserted.db-query.sh -thas no column headers to match a dict key by, socheck_scalar()treated a dict expect as purely descriptive — which is also the MOST NATURAL way to write a multi-column assertion (it's exactly howexpect.bodyworks for HTTP). A real bug in one of those columns printed green.lint-checklist.shnow flags it before the run: split into one scalarverify:row per column, or assert via the HTTP response'sjson_path. expect:had nocontainsoperator.expect: "contains idx_foo"fell to the exact-string fallback — the whole (often multi-line) scalar result compared against the literal string"contains idx_foo", never equal, so an EXPLAIN-plan-has-this-index assertion always failed regardless of whether the index was actually there. Addedcontains/not contains(substring match) tocheck_scalar/cmp, matching whatjson_path:assertions already supported.- A failed
teardown:step vanished into console-only output.teardown:is deliberately best-effort (never aborts the run), but a teardown step is often RECOVERY — undo a mutation, restore a row — and a failed recovery only printed a line to stdout; the caller had no way to know it happened. Its dirty leftover state then broke a LATER, unrelated test with no link back to the real cause.setup.run_steps()now returns the warning list instead of swallowing it;run-checklist.sh --jsonreports it asteardownWarnings, andverify-collectrecords it inVERIFICATION.mdunder its own heading — visible, not misattributed. sd-skeletonharvested only the FIRST FR-prefixed table. An SRS with FRs split across several sub-tables (one per module — 26 FRs across 4 tables is a real, common shape) reportedfr: 6and silently dropped the rest. Same bug for the TC/NFR ID-prefix fallbacks.tableByIdPrefixnow merges every matching table's rows instead of.find()-ing the first.sd-skeleton's §12.2 error-code harvest never read the SRS's own §6.2 "Error & Notification Messages" table — only per-story Edge Cases bullets, even though the generated §12.2 fallback text has said "derive from SRS §6.2" since Pass-1 existed. A project that lists its error codes in that dedicated table (rather than as story edge-case prose) harvested 0, with no signal. It's now read and merged, with the table's own Message column used as the real user-facing message instead of a TODO.trace-buildbuilt tables purely by content-matched header keywords, with no cross-check against the section NUMBER. A§12.2(or§5.1/§13.2/§10.4/§5.2) table whose column headers drifted — translated, renamed — silently produced 0 nodes for that whole section: the SD reads complete, the trace quietly drops it. Now warns by name when the numbered section exists with a table the content-matcher didn't recognize, quoting the actual headers found.trace-buildnever flagged an FR with zero linked TCs. An orphaned requirement — present in §5.1, uncovered by any §13.2 test — built a valid-looking trace. Now warned by FR id.checklist-gen's auth detection scanned the WHOLE multi-repo hub, majority-voting across everyconfig.reposentry — a feature scoped to one no-auth/HMAC repo in a hub of Summer/APISIX services got the hub'ssummerclassification and 401'd every generated test. Now resolves the feature's own repo scope first (--repos> declaredtrace-repos> file-links inference — the same precedenceverify-codealready uses) and detects against that one repo when it resolves unambiguously.detect-auth.shdefaulted an unrecognized custom scheme tono-auth. A gateway verifying its own HMAC/signature header (no Spring Security dep, noAuthorization: Bearer) has real access control, andno-authscaffolds unsigned tests that never exercise it. Added an HMAC/signature-pattern check (X-Signature,Mac.getInstance("Hmac..."),crypto.createHmac, …) that now classifies asunknowninstead — same safe-default fix as the 0.7.4 Summer/X-Userinfoflip, one auth family later.parse-prd/expand, run as a bare CLI subcommand, look like they finish in one shot — they don't. DefaulttaskCore.aiModeisagent-native: the command prints aGenerationSpecand exits 0 without calling an LLM (zero-network by design).commands/ingest.mdandcommands/phase.mdpresented it as a single self-sufficient bash command; the agent could read exit 0 + a large JSON blob as "done," runuse-tagsuccessfully, and leavetasks.jsonwith zero tasks for that tag — silent, textbook false-success. Both docs now spell out the handoff: parse the printed spec, generate theTask[]yourself (Phase 2), thentasks-import --file <path>(Phase 3) —{"imported": N}is the real signal, not the Phase 1 exit code.
Group 2 — noise, not wrong
srs-snapshot's filename-derived-slug warning only fired for a date-prefixed filename. Any other filename shape (phase-3-agentgw-client.md, drifting from a project's own naming convention) was silently adopted. Now warns whenever the slug came from the filename at all (noFeature:line, no--feature).checklist-genleakeddetect-auth.sh's stderr diagnostics into a caller that merges stdout+stderr. The engine's own stdout stayed pure JSON, butexecFileSync's defaultstdioinherits the child's stderr straight into this process's — any2>&1capture (or a tool wrapper that combines streams) saw prose land BEFORE the JSON and failed to parse it, even though the command had already succeeded. Now explicitly captured and discarded.checklist-genalways scaffoldedconfig.db/config.redis/cleanup:, even for a phase with no persistence at all.sd-template.md's own §7 convention is "no DB change → delete this section" — now honored: no §7 (or a §7 that says N/A/stateless/no database), no db/redis/cleanup block, with a warning explaining why.checklist-gensilently degenerated to one suite per test. When every §13.2 TC row carries a distinctFlowvalue (common for business-rule-derived TCs — 26 rules, 28 suites), grouping-by-Flow produces 28 singleton suites, and a singleton suite's lone test is ALWAYS taggedsmoke, neverregression(the rule needs ≥2 tests per flow to ever emit aregressiontag) — so--tag regressionsilently skips the entire checklist with no error. Now warned by name, with the count.genSd's §10.4 State Management forced aTODO:MANUAL-REVIEWmarker on every internal/hybrid phase, even one with no state machine at all.sd-template.md's own convention is "no state → delete this sub-section," the same carve-out §7 gets — but Pass-1 never offered it, so a plain batch job or pure transform carried a permanently unresolvable marker blocking approval until someone hand-deleted the section. Now a plain advisory note, not a TODO.
857 Node tests green (15 new), 68 Python checklist_lib unit tests green (3 new).