From 05da0314135cb7d87c11a72f320ac1abae549849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Mon, 19 Oct 2026 08:00:56 -0400 Subject: [PATCH 1/4] docs: ship an interactive flows page with every project Adds docs/flows.html, an interactive companion to the handbooks and ideation templates for spec authors, in the same shape as the loop's flows page. Six views: where the kit sits among the Specfuse repos; the initiative lifecycle (specs before the mint, and mint first) with the backlog state at each stage; a clickable map of a scaffolded project showing who owns each path; a skill finder over every skill and agent; the nine validation layers with a pick-a-mistake view of which layer catches what; and an upgrade simulator that follows scaffold.upgrade's manifest rules. The page is a new overlay entry, so init and upgrade put it at .specfuse/authoring/docs/flows.html and it always describes the installed kit. It opens from disk with no external script. Links into handbooks, samples, schemas and templates are relative and resolve both in this repo and in a project; repo-only docs are linked on GitHub. scripts/check-flows-page.py, run in CI, fails when the page drifts: skills and agents against the plugin, the finder's coverage, backlog states and the readiness checklist against the templates, layers against validate-specs.sh, cited Spectral rules against the rulesets, the overlay and quoted upgrade output against scaffold.py, and every relative link in both layouts. The smoke test also asserts the page lands in a fresh project byte-identical. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 + README.md | 1 + docs/flows.html | 801 +++++++++++++++++++++++++++++++++ docs/getting-started.md | 1 + pyproject.toml | 2 + scripts/check-flows-page.py | 142 ++++++ specfuse/authoring/scaffold.py | 7 +- 7 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 docs/flows.html create mode 100644 scripts/check-flows-page.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63cdecf..788582b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: python -m pip install --upgrade pip build python -m build --wheel --outdir dist + # docs/flows.html ships into every project; a kit change that would make + # it describe skills, states, layers or upgrade rules that no longer + # exist must fail here, not in a user's browser. + - name: Flows page matches the kit + run: python scripts/check-flows-page.py + - name: Clean-install and bootstrap from the built wheel run: | python -m venv /tmp/ci @@ -57,6 +63,7 @@ jobs: test -f /tmp/smoke/.specfuse/authoring/VERSION test -f /tmp/smoke/.specfuse/authoring/.scaffold-manifest test -f /tmp/smoke/.specfuse/authoring/schemas/spectral/specfuse-openapi.yaml + cmp docs/flows.html /tmp/smoke/.specfuse/authoring/docs/flows.html # The overlay's whole job is reaching projects that already exist. Each # case below is one the manifest was added to make possible — without it diff --git a/README.md b/README.md index 5601291..c67c09d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ See [`examples/hello-orders/`](examples/hello-orders/) for a complete worked exa | Look up a `x-*` extension | [`handbooks/Vendor_Extensions.md`](handbooks/Vendor_Extensions.md) | — | | Implement field encryption at runtime | [`handbooks/Field_Encryption_Runtime.md`](handbooks/Field_Encryption_Runtime.md) | — | | Configure the generator project file | [`handbooks/Project_File.md`](handbooks/Project_File.md) | — | +| See how it all fits together | [`docs/flows.html`](docs/flows.html), an interactive map of the lifecycle, project layout, skills, validation layers and upgrades. Every project gets a copy at `.specfuse/authoring/docs/flows.html`. | Open it in a browser | | See a complete worked example | [`examples/hello-orders/README.md`](examples/hello-orders/README.md) | — | ## Relationship to other Specfuse repos diff --git a/docs/flows.html b/docs/flows.html new file mode 100644 index 0000000..e53cce2 --- /dev/null +++ b/docs/flows.html @@ -0,0 +1,801 @@ + + + + + + +Specfuse Authoring Flows + + + + + + +
+
+
+

Specfuse Authoring Flows

+

How an idea becomes validated specs, what lives where in your project, which skill to reach for, and what specfuse authoring upgrade will do to your files.

+
+
+ a skill or the kit + you decide + generated or downstream + done here +
+
+ +
+ +
+
+ +

The handbooks and templates are the source of truth; this page is a map of them and links to each. It ships with the kit and specfuse authoring upgrade replaces it, so it describes the version you have installed. Skills are invoked as /specfuse-authoring:<name> once the plugin is installed.

+
+ + + + + + diff --git a/docs/getting-started.md b/docs/getting-started.md index e248ca1..93ffbce 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,6 +164,7 @@ To pull newer skills themselves, run `/plugin update specfuse-authoring@specfuse ## Where to go next +- [`flows.html`](flows.html) — interactive map of the lifecycle, project layout, skills, validation and upgrades; also in your project at `.specfuse/authoring/docs/flows.html` - [`README.md`](../README.md) — kit overview and asset map - [`handbooks/`](../handbooks/) — the full authoring contract - [`examples/hello-orders/`](../examples/hello-orders/) — a complete worked project diff --git a/pyproject.toml b/pyproject.toml index eed2fe5..f2d5d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ packages = ["specfuse"] "templates" = "specfuse/authoring/_kit/templates" "schemas" = "specfuse/authoring/_kit/schemas" "generator.lock" = "specfuse/authoring/_kit/generator.lock" +"docs/flows.html" = "specfuse/authoring/_kit/docs/flows.html" [tool.hatch.build.targets.sdist] include = [ @@ -51,6 +52,7 @@ include = [ "templates", "schemas", "generator.lock", + "docs/flows.html", "README.md", "LICENSE", ] diff --git a/scripts/check-flows-page.py b/scripts/check-flows-page.py new file mode 100644 index 0000000..c39a559 --- /dev/null +++ b/scripts/check-flows-page.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Fail when docs/flows.html drifts from the kit it describes. + +The flows page ships into every project as .specfuse/authoring/docs/flows.html +and keeps its facts in one JSON block (#kit-data). This check compares that +block with the sources it summarises, so a kit change that would make the page +wrong fails CI instead of shipping a stale map: + + - skills and agents against plugins/specfuse-authoring/{skills,agents} + - the skill finder covers every skill and names no unknown one + - backlog states against the State: line of the backlog template + - readiness checklist against the idea dossier template + - validation layers against LAYERS in scripts/specfuse/validate-specs.sh + - Spectral rule names against the kit's rulesets + - upgrade overlay against scaffold.OVERLAY, and the simulator's quoted + output against the strings scaffold.py prints + - relative links resolve both in this repo and in an installed project + +Usage: python3 scripts/check-flows-page.py (exit 1 on drift) +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +PAGE = ROOT / "docs" / "flows.html" +INSTALLED_PAGE = PurePosixPath(".specfuse/authoring/docs/flows.html") + +sys.path.insert(0, str(ROOT)) +from specfuse.authoring import scaffold # noqa: E402 + +errors: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + errors.append(msg) + + +def same_set(label: str, page: set[str], source: set[str]) -> None: + if missing := source - page: + errors.append(f"{label}: missing from the page: {sorted(missing)}") + if extra := page - source: + errors.append(f"{label}: on the page but not in the kit: {sorted(extra)}") + + +html = PAGE.read_text(encoding="utf-8") +m = re.search(r'', html, re.S) +if not m: + sys.exit("check-flows-page: no #kit-data block in docs/flows.html") +data = json.loads(m.group(1)) + +# Skills and agents. +plugin = ROOT / "plugins" / "specfuse-authoring" +skill_dirs = {p.parent.name for p in (plugin / "skills").glob("*/SKILL.md")} +agent_files = {p.stem for p in (plugin / "agents").glob("*.md")} +same_set("skills", set(data["skills"]), skill_dirs) +same_set("agents", set(data["agents"]), agent_files) + +intent_skills = {s for g in data["intents"] for _, s in g["items"] if s} +same_set("skill finder", intent_skills, skill_dirs) +for lc in data["lifecycles"]: + for st in lc["stages"]: + for s in filter(None, [st.get("skill"), *st.get("also", [])]): + check(s in skill_dirs, f"lifecycle {lc['id']!r} stage {st['t']!r} names unknown skill {s!r}") + check(st["state"] in data["backlogStates"], f"lifecycle stage {st['t']!r} has unknown state {st['state']!r}") +for name, agent in data["agents"].items(): + for s in agent["by"]: + check(s in skill_dirs, f"agent {name!r} is used by unknown skill {s!r}") + +# Backlog states: "State: `idea` → `shaping` → … (or `delivered` / `parked` / `dropped`)". +backlog = (ROOT / "templates" / "initiative-backlog.template.md").read_text(encoding="utf-8") +state_line = next((para.replace("\n", " ") for para in backlog.split("\n\n") if para.startswith("State: `")), "") +state_line = state_line.split(". ", 1)[0] +check(bool(state_line), "backlog template has no 'State: `…`' line") +source_states = re.findall(r"`([a-z]+)`", state_line) +check(data["backlogStates"] == source_states, + f"backlog states {data['backlogStates']} != template {source_states}") +side = re.search(r"\(or (.*?)\)", state_line) +check(bool(side) and set(data["backlogSide"]) == set(re.findall(r"`([a-z]+)`", side.group(1))), + "backlogSide does not match the template's '(or …)' states") + +# Readiness checklist. +dossier = (ROOT / "templates" / "initiative-idea-dossier.template.md").read_text(encoding="utf-8") +section = dossier.split("## Readiness", 1)[1].split("\n## ", 1)[0] if "## Readiness" in dossier else "" +source_ready = re.findall(r"^- \[ \] (.+)$", section, re.M) +check(data["readiness"] == source_ready, f"readiness {data['readiness']} != dossier template {source_ready}") + +# Validation layers, in order. +suite = (ROOT / "templates/project-init/scripts/specfuse/validate-specs.sh").read_text(encoding="utf-8") +block = re.search(r"LAYERS=\((.*?)\n\)", suite, re.S) +source_layers = re.findall(r'"([^"]+?)::\$SCRIPT_DIR/([\w.-]+)', block.group(1)) if block else [] +page_layers = [(l["label"], l["script"]) for l in data["layers"]] +check(page_layers == source_layers, f"validation layers {page_layers} != validate-specs.sh {source_layers}") +for mk in data["mistakes"]: + for i in mk["hit"] + mk.get("cascade", []): + check(0 <= i < len(data["layers"]), f"mistake {mk['t']!r} points at layer {i}, which does not exist") + +rulesets = "".join(p.read_text(encoding="utf-8") for p in (ROOT / "schemas" / "spectral").glob("specfuse-*.yaml")) +for mk in data["mistakes"]: + if rule := mk.get("rule"): + check(re.search(rf"^ {re.escape(rule)}:$", rulesets, re.M) is not None, + f"mistake {mk['t']!r} cites Spectral rule {rule!r}, which no kit ruleset defines") + +# Upgrade overlay and the simulator's quoted output. +same_set("upgrade overlay", set(data["overlay"]), {dest for _, dest in scaffold.OVERLAY}) +# Join implicitly concatenated literals ("…locally-"\n f"modified…") so quoted output matches. +scaffold_src = re.sub(r'"\s*\n\s*f?"', "", Path(scaffold.__file__).read_text(encoding="utf-8")) +for key, text in data["warnings"].items(): + check(text in scaffold_src, f"simulator quotes {text!r} ({key}), which scaffold.py no longer prints") + +# Links: relative ones must resolve here and in an installed project. +hrefs = re.findall(r'link\(\[\s*"[^"]*",\s*"([^"]+)"', html) +hrefs += re.findall(r'href="([^"$]+)"', html) +hrefs += [h for p in data["planes"] for _, h in p["links"]] +hrefs += [n["link"][1] for n in data["tree"] if "link" in n] +installed = {PurePosixPath(rel) for rel, _ in scaffold.overlay_files()} +for href in sorted(set(hrefs)): + if href.startswith(("http://", "https://", "#")): + continue + path = href.split("#", 1)[0] + check((PAGE.parent / path).resolve().is_file(), f"link {href!r} does not resolve from docs/") + parts: list[str] = [] + for part in (INSTALLED_PAGE.parent / path).parts: + if part == "..": + parts.pop() + else: + parts.append(part) + check(PurePosixPath(*parts) in installed, + f"link {href!r} would be broken in an installed project: the kit does not ship {PurePosixPath(*parts)}") + +if errors: + print("docs/flows.html has drifted from the kit:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + sys.exit(1) +print(f"docs/flows.html matches the kit ({len(skill_dirs)} skills, {len(agent_files)} agents, " + f"{len(source_layers)} validation layers, {len(data['overlay'])} overlay entries).") diff --git a/specfuse/authoring/scaffold.py b/specfuse/authoring/scaffold.py index 7c3407e..08b1ae7 100644 --- a/specfuse/authoring/scaffold.py +++ b/specfuse/authoring/scaffold.py @@ -23,8 +23,8 @@ Without the manifest all three look the same on disk, and the only safe behaviours are "clobber everything" or "touch nothing". -What the overlay owns is kit content only: the handbooks, samples and schemas -under `.specfuse/authoring/`, plus the tooling at `scripts/specfuse/`. Note the +What the overlay owns is kit content only: the handbooks, samples, schemas and +flows page under `.specfuse/authoring/`, plus the tooling at `scripts/specfuse/`. Note the subdirectory — `scripts/` itself is the project's, and hosts tooling from other contracts (the shared substrate's `validate-event.py`, authoring #26). Prune is scoped to `scripts/specfuse/`, so a kit upgrade cannot reach them. @@ -83,6 +83,9 @@ "templates/initiative-idea-dossier.template.md", ".specfuse/authoring/templates/initiative-idea-dossier.template.md", ), + # The interactive flows page. Only this one file from docs/ ships: the rest + # of docs/ is kit-repo documentation, and the page links to it on GitHub. + ("docs/flows.html", ".specfuse/authoring/docs/flows.html"), ("templates/project-init/scripts/specfuse", "scripts/specfuse"), ) From 9c2521c911dad0c20f85e1c81682b141d9c3c218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Mon, 19 Oct 2026 08:16:44 -0400 Subject: [PATCH 2/4] docs(flows): add a state machine view and the no-orchestrator path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flows page gains a seventh view, State machine: the idea machine and the initiative machine drawn as clickable diagrams, mirroring specfuse/specfuse docs/unit-lifecycles.md (updated alongside for spec-before-mint). Selecting a state lists how it is reached and left; selecting an arrow names the skill, agent or person that moves it, what it writes, and what it needs. A toggle switches to a project without an orchestration repo: transitions that need it grey out, the initiative machine is marked as not existing, the undocumented `ready → delivered` ending shows as a gap, and a table gives every skill's behaviour there (works, degrades, unavailable, idle). "Idea to specs" gains a third path, No orchestrator, from capture to closing the idea by hand. check-flows-page.py now also checks that the idea machine draws exactly the template's states, that every transition names a real skill and state, and that the no-orchestrator table covers every skill. Co-Authored-By: Claude Opus 5.5 --- docs/flows.html | 673 +++++++++++++++++++++++++++++++++++- scripts/check-flows-page.py | 19 +- 2 files changed, 680 insertions(+), 12 deletions(-) diff --git a/docs/flows.html b/docs/flows.html index e53cce2..ecbaf94 100644 --- a/docs/flows.html +++ b/docs/flows.html @@ -210,6 +210,50 @@ section.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; margin-top: 22px; } section.panel h3 { font-family: var(--display); font-size: 19px; margin: 0 0 4px; } section.panel > p { color: var(--muted); margin: 0 0 10px; max-width: 72ch; } +/* State machine */ +.machine { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow-x: auto; } +.machine svg { display: block; min-width: 720px; width: 100%; height: auto; } +.machine .st rect { fill: var(--surface); stroke: var(--accent); stroke-width: 2; } +.machine .st.end rect { stroke: var(--ok); } +.machine .st.side rect { stroke-dasharray: 5 4; } +.machine .st text { fill: var(--ink); font: 600 13.5px var(--mono); text-anchor: middle; dominant-baseline: central; pointer-events: none; } +.machine .st, .machine .ed { cursor: pointer; } +.machine .st.sel rect { fill: var(--accent-soft); stroke-width: 3; } +.machine .st.end.sel rect { fill: var(--ok-soft); } +.machine .st.off rect { stroke: var(--line); } +.machine .st.off text { fill: var(--muted); } +.machine .ed path.line { fill: none; stroke: var(--muted); stroke-width: 1.6; } +.machine .ed path.hit { fill: none; stroke: transparent; stroke-width: 16; } +.machine .ed text { fill: var(--muted); font: 500 11.5px var(--body); text-anchor: middle; paint-order: stroke; stroke: var(--surface); stroke-width: 4px; stroke-linejoin: round; } +.machine .ed.dashed path.line { stroke-dasharray: 5 4; } +.machine .ed.off path.line { stroke: var(--line); stroke-dasharray: 2 4; } +.machine .ed.off text { fill: var(--muted); opacity: .6; } +.machine .ed.gap path.line { stroke: var(--stop); stroke-dasharray: 6 4; } +.machine .ed.gap text { fill: var(--stop); } +.machine .ed.sel path.line { stroke: var(--ink); stroke-width: 2.6; } +.machine .ed.sel text { fill: var(--ink); font-weight: 600; } +.machine .ed:hover path.line { stroke: var(--ink); } +.machine .ed:focus-visible, .machine .st:focus-visible { outline: none; } +.machine .ed:focus-visible path.line, .machine .st:focus-visible rect { stroke: var(--ink); stroke-width: 3; } +.machine .start { fill: var(--ink); } +.machine .veil { fill: var(--surface); opacity: .8; } +.machine .veiltext { fill: var(--stop); font: 600 15px var(--body); text-anchor: middle; } +.mach-head { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; margin: 22px 0 8px; } +.mach-head h3 { font-family: var(--display); font-size: 19px; margin: 0; } +.mach-head span { font-size: 13px; color: var(--muted); } +.mach-board { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 320px); gap: 18px; align-items: start; } +.mach-board .detail { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 12px); } +.badge { display: inline-block; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 5px; } +.badge.works { background: var(--ok-soft); color: var(--ok); } +.badge.degrades { background: var(--human-soft); color: var(--human); } +.badge.unavailable { background: var(--stop-soft); color: var(--stop); } +.badge.idle { background: var(--bg); color: var(--muted); border: 1px solid var(--line); } +table.cap { border-collapse: collapse; width: 100%; font-size: 14px; } +table.cap td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +table.cap td:first-child { white-space: nowrap; font-family: var(--mono); font-size: 13px; } +.trans { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; font-size: 14px; } +.trans button { all: unset; cursor: pointer; color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } +.trans button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .foot { margin-top: 40px; font-size: 13px; color: var(--muted); max-width: 80ch; } @media (max-width: 980px) { @@ -222,7 +266,8 @@ nav.views button { white-space: nowrap; } nav.views button .s { display: none; } nav.views button[aria-selected="true"] { box-shadow: inset 0 -3px 0 var(--accent); } - .board, .finder, .sim { grid-template-columns: minmax(0, 1fr); } + .board, .finder, .sim, .mach-board { grid-template-columns: minmax(0, 1fr); } + .mach-board .detail { position: static; } .own { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 560px) { @@ -262,6 +307,7 @@

Specfuse Authoring Flows

"views": [ {"id": "picture", "name": "Big picture", "sub": "Where the kit sits"}, {"id": "lifecycle", "name": "Idea to specs", "sub": "The initiative lifecycle"}, + {"id": "states", "name": "State machine", "sub": "Every state and transition"}, {"id": "structure", "name": "Your project", "sub": "What lives where"}, {"id": "skills", "name": "Which skill?", "sub": "Find the right command"}, {"id": "validation", "name": "Validation", "sub": "What catches what"}, @@ -289,9 +335,9 @@

Specfuse Authoring Flows

"body": "prepare-handoff runs the full validation suite, regenerates scenario docs and bundles, builds the operation, async, entity and scenario inventories, and has the handoff-composer agent write one manifest. It writes nothing outside your repo.", "facts": [["Skill", "/specfuse-authoring:prepare-handoff"], ["Backlog effect", "The idea becomes specified"]], "links": [["Backlog template", "../templates/initiative-backlog.template.md"]]}, - {"id": "orchestrator", "kind": "human", "row": "down", "title": "Orchestration repo", "sub": "Registry entry and the INIT- mint", "path": "features/INIT-YYYY-NNNN.md", + {"id": "orchestrator", "kind": "human", "row": "down", "title": "Orchestration repo", "sub": "Registry and the INIT- mint · optional", "path": "features/INIT-YYYY-NNNN.md", "body": "A person decides to implement. initiative-intake mints the INIT-YYYY-NNNN correlation ID, writes the registry entry and emits initiative_created. From a manifest the initiative starts at planning, and decomposition into per-repo work reads the manifest's component inventory instead of guessing it.", - "facts": [["Skill", "/specfuse-authoring:initiative-intake"], ["Configured by", "repos.orchestrator in .specfuse/authoring/config.yml"]], + "facts": [["Skill", "/specfuse-authoring:initiative-intake"], ["Configured by", "repos.orchestrator in .specfuse/authoring/config.yml"], ["Without one", "Ideation, design, validation and generation still work; handoff and intake do not. See the State machine view."]], "links": [["Backlog template", "../templates/initiative-backlog.template.md"]]}, {"id": "loop", "kind": "gen", "row": "down", "title": "Implementation", "sub": "Component repos and their agents", "path": "specfuse:spec-issue", "body": "Component repos implement against your specs. When an implementing agent finds a problem in a spec it files an issue labelled specfuse:spec-issue; spec-issue-triage decides whether the fix is a spec correction you make here or a generator template bug to report upstream.", @@ -370,7 +416,505 @@

Specfuse Authoring Flows

"body": "Runs Specfuse validation, turns failures into actionable feedback, and owns the drafting → validating → planning transitions. It emits spec_validated and never transitions on a failed run. At planning the PM agent takes over.", "stops": ["You ask for validation; on failure you fix and run again."]} ], - "aside": "Both paths are legal. What is never right is dispatching implementation against specs that do not exist."} + "aside": "Both paths are legal. What is never right is dispatching implementation against specs that do not exist."}, + {"id": "standalone", "name": "No orchestrator", "tag": "kit only", + "summary": "A specs repo with the kit but no orchestration repo: nothing to hand off to and nothing to mint into. Ideation, design, validation and generation all work. Handoff, intake, spec-drafting and spec-validation do not, because each reads or writes the orchestration repo.", + "stages": [ + {"t": "Capture the idea", "skill": "ideation-capture", "kind": "auto", "state": "idea", + "body": "Unchanged. Capture never touches the orchestration repo.", + "writes": "docs/product/INITIATIVE_BACKLOG.md and a dossier"}, + {"t": "Shape it to ready", "skill": "ideation-shape", "kind": "human", "state": "ready", + "body": "Works from the backlog alone. The one check it cannot make is overlap with already-minted initiatives; the report says Overlap against minted work was not checked instead of stopping.", + "stops": ["All four readiness items checked."], "readiness": true}, + {"t": "Publish to GitHub", "skill": "ideation-publish", "kind": "human", "state": "ready", "optional": true, + "body": "Optional and unchanged. It needs github.ideas.repo in config.yml, not an orchestrator. A GitHub issue and Project board can stand in for the planning an orchestrator would do."}, + {"t": "Author the specs", "skill": "design-scenario", "also": ["design-async", "design-recipe", "create-job", "create-worker"], "kind": "auto", "state": "ready", + "body": "Interactively with the design skills, or as a loop feature if the Specfuse loop is installed (then set graduated_to and started). spec-drafting is not available: it works inside a minted initiative.", + "writes": "api/specs/v1/"}, + {"t": "Validate", "skill": "validate", "also": ["validate-async", "validate-scenarios", "review-scenario"], "kind": "auto", "state": "ready", + "body": "The full suite runs entirely in your repo. It replaces spec-validation, which records its result in the registry.", + "cmd": "./scripts/specfuse/validate-specs.sh"}, + {"t": "Generate and build", "skill": "generate", "also": ["bundle"], "kind": "auto", "state": "ready", + "body": "Generate code and docs from the validated specs and implement against them yourself. There is no handoff manifest: prepare-handoff stops at its first step without the orchestration repo's handoff contract.", + "writes": "Generated code, api/docs/flows/, docs/generated/"}, + {"t": "Close the idea", "kind": "end", "state": "delivered", + "body": "No skill takes an idea past ready here: specified needs a manifest and minted needs a registry. Record delivered by hand in the dossier and the row once the specs are authored and validated.", + "stops": ["You record delivered, or parked / dropped. The templates describe delivered as following specified, so this ending is not documented there yet; see the State machine view."]} + ], + "aside": "ideation-groom still runs and grades every class except overtaken. It keeps listing ready ideas as ready to mint, which here you can read as ready to close."} + ], + + "machine": { + "source": "https://github.com/specfuse/specfuse/blob/main/docs/unit-lifecycles.md", + "backlog": { + "viewBox": "0 0 900 400", + "start": { + "x": 20, + "y": 200 + }, + "states": { + "idea": { + "x": 105, + "y": 200, + "meaning": "Captured and not yet shaped. A one-line row and a stub dossier is a valid, encouraged state: the backlog's job is to lose no idea.", + "sets": "idea_id, slug" + }, + "shaping": { + "x": 270, + "y": 200, + "meaning": "The dossier is being worked: context, scope, target repos, readiness checklist. It stays here until every readiness item is checked and no [blocking] unknown remains.", + "sets": "target_repos, bundles / bundled_into" + }, + "ready": { + "x": 440, + "y": 200, + "meaning": "Cleared to have its specs authored, not cleared to mint. Authoring is not a backlog state: when a loop feature starts authoring the specs, the dossier records graduated_to and started, and the idea stays ready until the manifest is published.", + "sets": "graduated_to, started" + }, + "specified": { + "x": 625, + "y": 200, + "meaning": "The specs exist and a handoff manifest is published. A decision is owed: implement it or record it as delivered. A waypoint, never a resting place; ideation-groom reports one that ages.", + "sets": "handoff" + }, + "minted": { + "x": 815, + "y": 200, + "meaning": "Graduated: an INIT-YYYY-NNNN exists in the orchestration repo. The row stays as a breadcrumb; the registry takes over. Bundled rows flip with the lead.", + "sets": "minted_init", + "end": true + }, + "delivered": { + "x": 625, + "y": 345, + "meaning": "The terminal state for spec-only work: the spec tree was the whole deliverable, so no INIT- is minted. A deliberate act a person records, not a state a row decays into.", + "end": true + }, + "parked": { + "x": 190, + "y": 62, + "meaning": "A good idea at the wrong time. Kept, and revisited at groom.", + "side": true + }, + "dropped": { + "x": 530, + "y": 62, + "meaning": "Decided against. The row and dossier stay with a one-line reason, so the idea is not proposed again.", + "end": true, + "side": true + } + }, + "edges": [ + { + "id": "capture", + "from": "start", + "to": "idea", + "by": "ideation-capture", + "cond": "Any time. A typed title, or --from-issue for an idea someone filed on GitHub.", + "writes": "A row in docs/product/INITIATIVE_BACKLOG.md and docs/product/backlog/IDEA-NNN-.md" + }, + { + "id": "shape", + "from": "idea", + "to": "shaping", + "by": "ideation-shape", + "label": "shape", + "cond": "The first substantive edit to the dossier.", + "writes": "Dossier and row state", + "degrades": "Overlap with already-minted initiatives is not checked; the report says so." + }, + { + "id": "ready", + "from": "shaping", + "to": "ready", + "by": "ideation-shape", + "label": "4/4 ready", + "cond": "All four readiness items genuinely checked and no [blocking] unknown left. The skill then offers ideation-publish.", + "writes": "Dossier and row state", + "degrades": "Overlap with already-minted initiatives is not checked." + }, + { + "id": "reshape", + "from": "ready", + "to": "shaping", + "by": "ideation-groom", + "label": "under-shaped", + "bend": -46, + "cond": "Groom finds the readiness boxes checked but the dossier thin or unsupported. Reverted when you accept the report.", + "writes": "The backlog index" + }, + { + "id": "handoff", + "from": "ready", + "to": "specified", + "by": "prepare-handoff", + "label": "handoff", + "needs": "orchestrator", + "cond": "The specs are authored and the whole suite validates. The manifest carries the id of the loop feature that authored the specs.", + "writes": "api/docs/handoffs/.md; the dossier's handoff field", + "why": "prepare-handoff reads the handoff contract from the orchestration repo and stops at its first step when that repo cannot be resolved." + }, + { + "id": "mint-direct", + "from": "ready", + "to": "minted", + "by": "initiative-intake", + "label": "mint first", + "bend": 112, + "needs": "orchestrator", + "cond": "Mint-first path: a ready idea with no graduated_to. The specs will be drafted inside the INIT-, starting at drafting.", + "writes": "The registry entry in the orchestration repo; minted_init on the dossier", + "why": "Intake writes the registry entry in the orchestration repo and mints the id from it.", + "ldx": 78 + }, + { + "id": "mint", + "from": "specified", + "to": "minted", + "by": "initiative-intake", + "label": "mint", + "needs": "orchestrator", + "cond": "A person decides to implement. Intake reads the dossier and the manifest; the initiative starts at planning.", + "writes": "The registry entry with specs_source; minted_init on the dossier", + "why": "Intake writes the registry entry in the orchestration repo and mints the id from it." + }, + { + "id": "deliver", + "from": "specified", + "to": "delivered", + "by": null, + "label": "spec-only", + "cond": "The spec tree is the whole deliverable; no component repo will implement it as an initiative.", + "writes": "Dossier and row state, by hand. On a published idea, ideation-publish shows the reason before posting it.", + "ldy": 40, + "ldx": -30 + }, + { + "id": "gap", + "from": "ready", + "to": "delivered", + "by": null, + "label": "by hand", + "bend": -30, + "gap": true, + "only": "standalone", + "cond": "Without an orchestration repo no skill moves an idea past ready: prepare-handoff cannot publish a manifest, so specified is unreachable, and there is nothing to mint into. The workable ending today is to record delivered by hand once the specs are authored and validated.", + "writes": "Dossier and row state, by hand", + "why": "The templates describe delivered as following specified, so this ending is not documented there yet." + }, + { + "id": "park-idea", + "from": "idea", + "to": "parked", + "by": "ideation-groom", + "cond": "Stale: untouched a long while, no momentum. Parked when you accept the groom report.", + "writes": "The backlog index" + }, + { + "id": "park-shaping", + "from": "shaping", + "to": "parked", + "by": "ideation-groom", + "bend": 18, + "cond": "Stale: untouched a long while, no momentum.", + "writes": "The backlog index" + }, + { + "id": "revisit", + "from": "parked", + "to": "shaping", + "by": null, + "bend": 18, + "dashed": true, + "cond": "Picked back up. Parked ideas are revisited at groom; resuming one is your call.", + "writes": "Row state, then ideation-shape" + }, + { + "id": "drop-parked", + "from": "parked", + "to": "dropped", + "by": null, + "cond": "Decided against after parking. Record a one-line reason.", + "writes": "Row state and reason" + }, + { + "id": "drop-idea", + "from": "idea", + "to": "dropped", + "by": null, + "cond": "Decided against. The row is kept with a one-line reason, never deleted.", + "writes": "Row state and reason" + }, + { + "id": "drop-shaping", + "from": "shaping", + "to": "dropped", + "by": null, + "cond": "Decided against, for example because it duplicates already-minted work.", + "writes": "Row state and reason" + }, + { + "id": "drop-ready", + "from": "ready", + "to": "dropped", + "by": "ideation-groom", + "cond": "Overtaken: groom finds it overlaps an already-minted INIT- and recommends dropped with a link.", + "writes": "The backlog index", + "degrades": "Without an orchestration repo groom cannot see minted work, so it reports overtaken as not assessed." + } + ] + }, + "registry": { + "viewBox": "0 0 900 340", + "start": { + "x": 22, + "y": 70 + }, + "states": { + "drafting": { + "x": 140, + "y": 70, + "meaning": "Minted from a dossier; the specs are being drafted with spec-drafting and the design skills." + }, + "validating": { + "x": 310, + "y": 70, + "meaning": "spec-validation has run or is running. A failed run emits no transition and leaves the initiative here; it never goes back to drafting." + }, + "planning": { + "x": 480, + "y": 70, + "meaning": "The plane handoff has happened: the pm agent owns the initiative and decomposes it into a feature graph." + }, + "plan_review": { + "x": 650, + "y": 70, + "meaning": "The pm has proposed a feature graph and waits for a person to approve it. The pm never approves its own plan." + }, + "generating": { + "x": 820, + "y": 70, + "meaning": "Approved. The pm generates code and opens one specfuse:feature issue per feature, per repo." + }, + "in_progress": { + "x": 820, + "y": 205, + "meaning": "Component repos are running their loop features. The merge watcher closes tasks as PRs merge." + }, + "done": { + "x": 650, + "y": 205, + "meaning": "The last feature is done and the pm has written the cross-component retro.", + "end": true + }, + "blocked": { + "x": 310, + "y": 205, + "meaning": "An agent escalated. A person resolves the escalation and the initiative returns to drafting.", + "side": true + }, + "abandoned": { + "x": 820, + "y": 300, + "meaning": "Stopped by a person, or by the pm cascading an abandonment.", + "end": true, + "side": true + } + }, + "edges": [ + { + "id": "r-intake", + "from": "start", + "to": "drafting", + "by": "initiative-intake", + "cond": "Mint first: intake mints the INIT-, writes the registry entry and emits initiative_created.", + "writes": "features/INIT-YYYY-NNNN.md in the orchestration repo" + }, + { + "id": "r-manifest", + "from": "start", + "to": "planning", + "by": "initiative-intake", + "label": "from a manifest", + "bend": -58, + "cond": "Spec-before-mint: the specs were authored and validated under gates before the mint, so drafting and validating are skipped. The registry records specs_source.", + "writes": "features/INIT-YYYY-NNNN.md with specs_source" + }, + { + "id": "r-validate", + "from": "drafting", + "to": "validating", + "by": "spec-validation", + "label": "validate", + "cond": "You ask for validation. The transition is emitted before the validator runs, so the log records the attempt even if it crashes.", + "writes": "Registry state and an event" + }, + { + "id": "r-fail", + "from": "validating", + "to": "validating", + "by": "spec-validation", + "label": "fails", + "self": true, + "cond": "Errors found: no transition is emitted. Fix the specs and run validation again from here. Three failures in a row raise a spinning_detected escalation.", + "writes": "Nothing in the registry state" + }, + { + "id": "r-pass", + "from": "validating", + "to": "planning", + "by": "spec-validation", + "label": "clean pass", + "cond": "Zero errors. This is the plane handoff from the specs agent to the pm.", + "writes": "Registry state and events" + }, + { + "id": "r-plan", + "from": "planning", + "to": "plan_review", + "by": null, + "label": "pm plans", + "agent": "pm", + "cond": "The pm decomposes the initiative into a feature graph and writes a plan-review file.", + "writes": "Feature graph INIT-…/FNN, plan-review file" + }, + { + "id": "r-reject", + "from": "plan_review", + "to": "planning", + "by": null, + "label": "rejected", + "bend": 34, + "cond": "A person rejects the plan; the pm plans again.", + "writes": "Registry state" + }, + { + "id": "r-approve", + "from": "plan_review", + "to": "generating", + "by": null, + "label": "approved", + "cond": "The approval gate. A person approves; the pm never self-approves.", + "writes": "Registry state" + }, + { + "id": "r-dispatch", + "from": "generating", + "to": "in_progress", + "by": null, + "label": "dispatched", + "agent": "pm", + "cond": "The first task issue is opened in a component repo.", + "writes": "specfuse:feature issues" + }, + { + "id": "r-done", + "from": "in_progress", + "to": "done", + "by": null, + "label": "all merged", + "agent": "merge watcher, then pm", + "cond": "The last feature is done and the pm writes the retro.", + "writes": "Root LEARNINGS.md" + }, + { + "id": "r-abandon", + "from": "in_progress", + "to": "abandoned", + "by": null, + "cond": "A person stops the initiative, or the pm cascades an abandonment.", + "writes": "Registry state" + }, + { + "id": "r-block-d", + "from": "drafting", + "to": "blocked", + "by": null, + "agent": "any agent", + "cond": "Escalation. Any agent can block an initiative from drafting, validating, planning, generating or in_progress.", + "writes": "Registry state" + }, + { + "id": "r-block-v", + "from": "validating", + "to": "blocked", + "by": null, + "agent": "any agent", + "cond": "Escalation, for example spinning_detected after three failed validations.", + "writes": "Registry state", + "bend": -52 + }, + { + "id": "r-block-p", + "from": "planning", + "to": "blocked", + "by": null, + "agent": "any agent", + "cond": "Escalation.", + "writes": "Registry state" + }, + { + "id": "r-block-g", + "from": "generating", + "to": "blocked", + "by": null, + "agent": "any agent", + "cond": "Escalation.", + "writes": "Registry state" + }, + { + "id": "r-block-i", + "from": "in_progress", + "to": "blocked", + "by": null, + "agent": "any agent", + "bend": -40, + "cond": "Escalation.", + "writes": "Registry state" + }, + { + "id": "r-unblock", + "from": "blocked", + "to": "drafting", + "by": null, + "label": "resolved", + "bend": -30, + "cond": "A person resolves the escalation; the initiative returns to drafting.", + "writes": "Registry state" + } + ] + } + }, + + "standalone": [ + ["ideation-capture", "works", "Writes only to this repo."], + ["ideation-shape", "degrades", "Shapes from the backlog alone; overlap with minted work is not checked and the report says so."], + ["ideation-publish", "works", "Needs github.ideas.repo in config.yml, not the orchestrator."], + ["ideation-groom", "degrades", "Every class is graded except overtaken, reported as not assessed. It keeps listing ready ideas as ready to mint."], + ["initiative-intake", "unavailable", "Writes the registry entry in the orchestration repo and mints the INIT- from it."], + ["spec-drafting", "unavailable", "Works inside a minted INIT- and updates its registry entry. Use the design skills and the handbooks instead."], + ["spec-validation", "unavailable", "Owns the registry's drafting → validating → planning transitions. Use validate, validate-async and validate-scenarios instead."], + ["prepare-handoff", "unavailable", "Reads the handoff contract from the orchestration repo and stops at its first step without it."], + ["spec-issue-triage", "idle", "Spec issues are filed from the orchestrator side, so nothing arrives to triage."], + ["design-scenario", "works", ""], + ["update-scenario", "works", ""], + ["deprecate-scenario", "works", ""], + ["design-recipe", "works", ""], + ["design-flow", "works", ""], + ["design-async", "works", ""], + ["create-job", "works", ""], + ["create-worker", "works", ""], + ["list-scenarios", "works", ""], + ["review-scenario", "works", ""], + ["impact-scenarios", "works", ""], + ["validate", "works", ""], + ["validate-async", "works", ""], + ["validate-scenarios", "works", ""], + ["preview", "works", ""], + ["preview-async", "works", ""], + ["bundle", "works", ""], + ["generate", "works", "Needs generator access, not the orchestrator."], + ["file-generator-issue", "works", "Files against the generator repo on GitHub."] ], "tree": [ @@ -548,7 +1092,7 @@

Specfuse Authoring Flows

const slash = n => `/specfuse-authoring:${n}`; const link = ([text, href]) => `${esc(text)}`; const nav = $("#nav"), main = $("#main"); - const ui = { view: "picture", plane: "kit", life: "spec-first", stage: 0, node: 0, intent: null, query: "", mistake: 0, + const ui = { mode: "orchestrated", sel: { kind: "state", m: "backlog", id: "ready" }, view: "picture", plane: "kit", life: "spec-first", stage: 0, node: 0, intent: null, query: "", mistake: 0, sim: { area: "kit", ships: true, disk: "asWritten", manifest: true, version: "older" } }; function readHash() { @@ -619,6 +1163,101 @@

${esc(s.t)}

${L.aside}

States: idea → shaping → ready → specified → minted, with delivered, parked and dropped on the side. Read the backlog template for what each one means.

`; } + + /* ---------- State machine ---------- */ + const NW = 116, NH = 38; + function clipTo(c, toward) { + const dx = toward.x - c.x, dy = toward.y - c.y; + if (!dx && !dy) return { x: c.x, y: c.y }; + const t = Math.min(dx ? (NW / 2 + 3) / Math.abs(dx) : Infinity, dy ? (NH / 2 + 3) / Math.abs(dy) : Infinity); + return { x: c.x + dx * t, y: c.y + dy * t }; + } + function edgeGeom(M, e) { + const a = e.from === "start" ? M.start : M.states[e.from], b = M.states[e.to]; + if (e.self) { + const x = b.x, y = b.y + NH / 2; + return { d: `M ${x - 22} ${y} C ${x - 40} ${y + 58}, ${x + 40} ${y + 58}, ${x + 22} ${y}`, lx: x, ly: y + 58 }; + } + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const len = Math.hypot(b.x - a.x, b.y - a.y) || 1; + const bend = e.bend || 0; + const c = { x: mx - (b.y - a.y) / len * bend, y: my + (b.x - a.x) / len * bend }; + const p0 = e.from === "start" ? { x: a.x + 7, y: a.y } : clipTo(a, bend ? c : b); + const p1 = clipTo(b, bend ? c : a); + const lx = (p0.x + 2 * c.x + p1.x) / 4, ly = (p0.y + 2 * c.y + p1.y) / 4; + return { d: bend ? `M ${p0.x} ${p0.y} Q ${c.x} ${c.y} ${p1.x} ${p1.y}` : `M ${p0.x} ${p0.y} L ${p1.x} ${p1.y}`, lx: lx + (e.ldx || 0), ly: ly - 7 + (e.ldy || 0) }; + } + const standalone = () => ui.mode === "standalone"; + const edgeShown = e => !e.only || (e.only === "standalone") === standalone(); + const edgeOff = (m, e) => standalone() && (m === "registry" || e.needs === "orchestrator"); + const stateOff = (m, id) => standalone() && (m === "registry" || id === "specified" || id === "minted"); + function machineSvg(key) { + const M = D.machine[key]; + const edges = M.edges.filter(edgeShown).map(e => { + const g = edgeGeom(M, e); + const off = edgeOff(key, e); + const cls = ["ed", e.dashed ? "dashed" : "", e.gap ? "gap" : "", off ? "off" : "", ui.sel.kind === "edge" && ui.sel.id === e.id ? "sel" : ""].join(" "); + const mk = e.gap ? "gap" : off ? "off" : "on"; + return `${e.label ? `${esc(e.label)}` : ""}`; + }).join(""); + const nodes = Object.entries(M.states).map(([id, st]) => { + const cls = ["st", st.end ? "end" : "", st.side ? "side" : "", stateOff(key, id) ? "off" : "", ui.sel.kind === "state" && ui.sel.m === key && ui.sel.id === id ? "sel" : ""].join(" "); + return `${esc(id)}`; + }).join(""); + const [, , w, h] = M.viewBox.split(" ").map(Number); + const veil = key === "registry" && standalone() ? `No orchestration repo, so no registry: none of these states exist.` : ""; + const markers = [["on", "var(--muted)"], ["off", "var(--line)"], ["gap", "var(--stop)"]].map(([id, c]) => ``).join(""); + return `
${markers}${edges}${nodes}${veil}
`; + } + function edgeName(e) { return `${e.from === "start" ? "(new)" : e.from} → ${e.to}`; } + function edgeBy(e) { return e.by ? `${slash(e.by)}` : e.agent ? esc(`The ${e.agent}`) : "A person"; } + function machineDetail() { + const M = D.machine[ui.sel.m]; + if (ui.sel.kind === "state") { + const st = M.states[ui.sel.id]; + const ins = M.edges.filter(e => e.to === ui.sel.id && e.from !== ui.sel.id && edgeShown(e)); + const outs = M.edges.filter(e => e.from === ui.sel.id && edgeShown(e)); + const li = e => `
  • ${e.by ? esc(e.by) : e.agent ? esc(e.agent) : "a person"}${edgeOff(ui.sel.m, e) ? " · unavailable" : ""}
  • `; + const off = stateOff(ui.sel.m, ui.sel.id); + return `${off ? "unreachable without an orchestrator" : ui.sel.m === "backlog" ? "backlog state" : "initiative state"} +

    ${esc(ui.sel.id)}

    ${esc(st.meaning)}

    + ${st.sets ? `
    Dossier fields
    ${esc(st.sets)}
    ` : ""} + ${ins.length ? `

    Reached by

      ${ins.map(li).join("")}
    ` : ""} + ${outs.length ? `

    Leaves by

      ${outs.map(li).join("")}
    ` : ""}`; + } + const e = M.edges.find(x => x.id === ui.sel.id); + const off = edgeOff(ui.sel.m, e); + const [whoCls, whoTxt] = e.gap ? ["stop", "not documented yet"] : off ? ["stop", "unavailable without an orchestrator"] : e.by ? ["", "a skill does this"] : e.agent ? ["gen", "an agent does this"] : ["human", "a person decides"]; + return `${whoTxt} +

    ${esc(edgeName(e))}

    ${esc(e.cond)}

    +
    +
    By
    ${edgeBy(e)}
    + ${e.writes ? `
    Writes
    ${esc(e.writes)}
    ` : ""} + ${e.needs ? `
    Needs
    The orchestration repo (repos.orchestrator in config.yml)
    ` : ""} + ${(off || e.gap) && e.why ? `
    Why
    ${esc(e.why)}
    ` : ""} + ${standalone() && e.degrades ? `
    Without it
    ${esc(e.degrades)}
    ` : ""} +
    `; + } + function viewStates() { + const mode = [["orchestrated", "With an orchestration repo"], ["standalone", "Without one"]]; + const order = ["unavailable", "idle", "degrades", "works"]; + const cap = standalone() ? `

    What still works without an orchestrator

    Each skill when repos.orchestrator does not resolve. Skills that only read the orchestration repo degrade and say so; skills that write to it, or need a file it holds, cannot run.

    ${order.flatMap(k => D.standalone.filter(r => r[1] === k)).map(([n, k, why]) => ``).join("")}
    ${esc(n)}${k}${esc(why)}
    ` : ""; + return `

    State machine

    +

    Two state machines meet at the mint. The backlog lives in your repo, in each dossier's state: and its index row. The initiative lives in the orchestration repo's registry and starts when an INIT- is minted. Select a state or an arrow for what it means, who moves it and what it writes.

    +
    Project
    ${mode.map(([v, t]) => ``).join("")}
    +
    +
    +

    Idea

    docs/product/backlog/IDEA-NNN-<slug>.md
    + ${machineSvg("backlog")} +

    Initiative

    features/INIT-YYYY-NNNN.md in the orchestration repo
    + ${machineSvg("registry")} +
    +
    ${machineDetail()}
    +
    + ${cap} +

    Green boxes end a machine; dashed boxes are side states. ${standalone() ? "Grey arrows need the orchestration repo. The red arrow is the gap: an ending the kit does not document yet." : "Switch to Without one to see what changes when no orchestration repo is configured."} These diagrams mirror ${link(["Unit lifecycles", D.machine.source])}, which also covers features, gates and work units; the rules for each backlog state are in the ${link(["backlog template", "../templates/initiative-backlog.template.md"])}.

    `; + } + /* ---------- Structure ---------- */ function viewStructure() { const n = D.tree[ui.node]; @@ -753,7 +1392,7 @@

    ${esc(m.t)}

    Skills update separately

    Upgrade carries the contract the skills read. The skills and agents themselves come from the plugin: /plugin update specfuse-authoring@specfuse. Upgrade also refuses a downgrade, so install the newer CLI before upgrading a project someone else already moved forward.

    ${link(["Keep assets current", "https://github.com/specfuse/authoring/blob/main/docs/getting-started.md#7-keep-assets-current"])}

    `; } - const VIEWS = { picture: viewPicture, lifecycle: viewLifecycle, structure: viewStructure, skills: viewSkills, validation: viewValidation, upgrade: viewUpgrade }; + const VIEWS = { picture: viewPicture, lifecycle: viewLifecycle, states: viewStates, structure: viewStructure, skills: viewSkills, validation: viewValidation, upgrade: viewUpgrade }; function render() { renderNav(); main.setAttribute("aria-labelledby", "tab-" + ui.view); @@ -761,10 +1400,17 @@

    ${esc(m.t)}

    } main.addEventListener("click", e => { - const t = e.target.closest("[data-plane],[data-life],[data-stage],[data-node],[data-intent],[data-mistake],[data-sim]"); + const t = e.target.closest("[data-plane],[data-life],[data-stage],[data-node],[data-intent],[data-mistake],[data-sim],[data-state],[data-edge],[data-mode]"); if (!t) return; const d = t.dataset; - if (d.plane) ui.plane = d.plane; + if (d.mode) { + ui.mode = d.mode; + const cur = D.machine[ui.sel.m]; + if (ui.sel.kind === "edge" && !cur.edges.some(x => x.id === ui.sel.id && edgeShown(x))) ui.sel = { kind: "state", m: "backlog", id: "ready" }; + } + else if (d.state) ui.sel = { kind: "state", m: d.m, id: d.state }; + else if (d.edge) ui.sel = { kind: "edge", m: d.m, id: d.edge }; + else if (d.plane) ui.plane = d.plane; else if (d.life) { ui.life = d.life; ui.stage = 0; } else if (d.stage) ui.stage = +d.stage; else if (d.node) ui.node = +d.node; @@ -775,13 +1421,18 @@

    ${esc(m.t)}

    ui.sim[d.sim] = v; if (!ui.sim.ships && ui.sim.disk === "same") ui.sim.disk = "asWritten"; } - const sel = t.dataset.stage || t.dataset.node || t.dataset.plane; + const key = ["stage", "node", "plane", "state", "edge"].find(k => t.dataset[k] != null); + const inSvg = !!t.closest("svg"); render(); - if (sel != null) { - const again = main.querySelector(`[data-${t.dataset.stage ? "stage" : t.dataset.node ? "node" : "plane"}="${sel}"]`); + if (key && (inSvg || (key !== "state" && key !== "edge"))) { + const again = main.querySelector(`${inSvg ? "svg " : ""}[data-${key}="${t.dataset[key].replace(/"/g, '\\"')}"]`); if (again) again.focus({ preventScroll: true }); } }); + main.addEventListener("keydown", e => { + const g = e.target.closest && e.target.closest("svg [data-state], svg [data-edge]"); + if (g && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); g.dispatchEvent(new MouseEvent("click", { bubbles: true })); } + }); main.addEventListener("input", e => { if (e.target.id !== "skill-q") return; ui.query = e.target.value; diff --git a/scripts/check-flows-page.py b/scripts/check-flows-page.py index c39a559..b4ca0ee 100644 --- a/scripts/check-flows-page.py +++ b/scripts/check-flows-page.py @@ -8,7 +8,9 @@ - skills and agents against plugins/specfuse-authoring/{skills,agents} - the skill finder covers every skill and names no unknown one - - backlog states against the State: line of the backlog template + - backlog states against the State: line of the backlog template, + and the idea state machine against those states + - no-orchestrator table covers every skill - readiness checklist against the idea dossier template - validation layers against LAYERS in scripts/specfuse/validate-specs.sh - Spectral rule names against the kit's rulesets @@ -72,6 +74,21 @@ def same_set(label: str, page: set[str], source: set[str]) -> None: for s in agent["by"]: check(s in skill_dirs, f"agent {name!r} is used by unknown skill {s!r}") +# State machine: the idea machine draws exactly the template's states, every +# transition names a real skill, and the no-orchestrator table covers every skill. +machine = data["machine"] +same_set("idea state machine", set(machine["backlog"]["states"]), set(data["backlogStates"])) +for key in ("backlog", "registry"): + states = machine[key]["states"] + for e in machine[key]["edges"]: + check(e["from"] in states or e["from"] == "start", f"{key} edge {e['id']!r} starts at unknown state {e['from']!r}") + check(e["to"] in states, f"{key} edge {e['id']!r} ends at unknown state {e['to']!r}") + if e.get("by"): + check(e["by"] in skill_dirs, f"{key} edge {e['id']!r} names unknown skill {e['by']!r}") +same_set("no-orchestrator table", {r[0] for r in data["standalone"]}, skill_dirs) +for name, status, _ in data["standalone"]: + check(status in {"works", "degrades", "unavailable", "idle"}, f"no-orchestrator table: {name!r} has unknown status {status!r}") + # Backlog states: "State: `idea` → `shaping` → … (or `delivered` / `parked` / `dropped`)". backlog = (ROOT / "templates" / "initiative-backlog.template.md").read_text(encoding="utf-8") state_line = next((para.replace("\n", " ") for para in backlog.split("\n\n") if para.startswith("State: `")), "") From 39d2aff337565be6d33f48fed9fe9ad580947e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Mon, 19 Oct 2026 08:27:31 -0400 Subject: [PATCH 3/4] docs(flows): explain the project file, components and the loop Three new views on the flows page: - Project file: a clickable example -project.json for a project with three component repos. Each key explains what it does, whether it is required, and links its Project_File.md section; the keys that bite (stateDir, destination, cleanGenerated, cleanScope) stand out. Panels cover the four foot-guns, the blocks not in the example, and what the generator does with the file. - Specs and components: the spec repo fanning out to its component repos through each group's destination, with where generated code lives, how hand-written code extends it, build wiring and post-generate steps per language; then the five-step regen flow (spec, regen on a branch with generate.sh, component PRs, merge, advance-state.sh) and the house rules. - The loop: the three spec front-ends, an "I want to" picker that says whether to use a loop feature and where (spec repo or component), with the command chain and the rules for each case, and the loop/orchestrator comparison. check-flows-page.py now also checks that every key in the example is documented in Project_File.md's quick reference, that every artifact id is in the handbook's list for that group's language, that every Project_File.md anchor the page links exists, and that the loop picker names real skills. Co-Authored-By: Claude Opus 5.5 --- docs/flows.html | 667 +++++++++++++++++++++++++++++++++++- scripts/check-flows-page.py | 59 ++++ 2 files changed, 720 insertions(+), 6 deletions(-) diff --git a/docs/flows.html b/docs/flows.html index ecbaf94..61ccf4b 100644 --- a/docs/flows.html +++ b/docs/flows.html @@ -254,6 +254,50 @@ .trans { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; font-size: 14px; } .trans button { all: unset; cursor: pointer; color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .trans button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* Project file */ +.json { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 6px; font: 13px/1.6 var(--mono); overflow-x: auto; white-space: pre; } +.json .jk { all: unset; cursor: pointer; color: var(--accent); border-radius: 4px; padding: 0 2px; } +.json .jk.warn { color: var(--human); } +.json .jk:hover { background: var(--accent-soft); } +.json .jk[aria-current="true"] { background: var(--ink); color: var(--surface); } +.json .jk:focus-visible { outline: 2px solid var(--accent); } +.json .js { color: var(--ink); } +.json .jn { color: var(--gen); } +.json .jp { color: var(--muted); } +.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 10px; } +.cards div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font-size: 14px; min-width: 0; } +.cards b { display: block; margin-bottom: 2px; } +.cards.warn div { border-left: 3px solid var(--human); } +ol.steps { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; counter-reset: s; } +ol.steps li { counter-increment: s; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 13.5px; flex: 1 1 150px; min-width: 0; } +ol.steps li::before { content: counter(s); display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: var(--surface); font: 600 11px var(--mono); margin-right: 6px; } +ol.steps b { font-weight: 600; } +/* Components */ +.fan { display: grid; grid-template-columns: minmax(0, 240px) 40px minmax(0, 1fr); gap: 10px; align-items: stretch; } +.fan .src { background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--ink); border-radius: 8px; padding: 12px 14px; display: grid; gap: 4px; align-content: start; } +.fan .src b { font-family: var(--display); font-size: 17px; } +.fan .src span { font: 12px var(--mono); color: var(--muted); } +.fan .arrows { display: grid; place-items: center; color: var(--muted); font: 600 22px var(--mono); } +.fan .targets { display: grid; gap: 8px; } +.tgt { all: unset; cursor: pointer; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; background: var(--surface); border: 1px solid var(--line); border-left: 4px solid var(--accent); border-radius: 8px; padding: 8px 12px; } +.tgt.gen { border-left-color: var(--gen); } +.tgt .g { font-weight: 600; } +.tgt .l { font: 12px var(--mono); color: var(--muted); } +.tgt .d { grid-column: 1 / -1; font: 12px var(--mono); color: var(--muted); overflow-wrap: anywhere; } +.tgt[aria-current="true"] { box-shadow: 0 0 0 2px var(--ink); } +.tgt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.rules { margin: 0; padding-left: 18px; display: grid; gap: 4px; } +/* Loop */ +.fronts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; } +.fronts div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font-size: 14px; min-width: 0; } +.fronts div.on { box-shadow: 0 0 0 2px var(--accent); } +.fronts code { font-weight: 600; display: block; margin-bottom: 2px; } +.cmdchain { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin: 10px 0; } +.cmdchain code { background: var(--bg); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; font-size: 12.5px; } +.cmdchain i { color: var(--muted); font-style: normal; } +table.surf { border-collapse: collapse; width: 100%; font-size: 14px; } +table.surf th, table.surf td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +table.surf th { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } .foot { margin-top: 40px; font-size: 13px; color: var(--muted); max-width: 80ch; } @media (max-width: 980px) { @@ -268,7 +312,9 @@ nav.views button[aria-selected="true"] { box-shadow: inset 0 -3px 0 var(--accent); } .board, .finder, .sim, .mach-board { grid-template-columns: minmax(0, 1fr); } .mach-board .detail { position: static; } - .own { grid-template-columns: minmax(0, 1fr); } + .own, .cards, .fronts { grid-template-columns: minmax(0, 1fr); } + .fan { grid-template-columns: minmax(0, 1fr); } + .fan .arrows { transform: rotate(90deg); height: 30px; } } @media (max-width: 560px) { .chain { grid-template-columns: minmax(0, 1fr); } @@ -309,6 +355,9 @@

    Specfuse Authoring Flows

    {"id": "lifecycle", "name": "Idea to specs", "sub": "The initiative lifecycle"}, {"id": "states", "name": "State machine", "sub": "Every state and transition"}, {"id": "structure", "name": "Your project", "sub": "What lives where"}, + {"id": "projectfile", "name": "Project file", "sub": "-project.json"}, + {"id": "components", "name": "Specs and components", "sub": "Where generated code goes"}, + {"id": "loop", "name": "The loop", "sub": "When and how to use it"}, {"id": "skills", "name": "Which skill?", "sub": "Find the right command"}, {"id": "validation", "name": "Validation", "sub": "What catches what"}, {"id": "upgrade", "name": "Upgrades", "sub": "What happens to a file"} @@ -917,6 +966,521 @@

    Specfuse Authoring Flows

    ["file-generator-issue", "works", "Files against the generator repo on GitHub."] ], + "projectFile": { + "sample": { + "name": "hello-orders", + "description": "Orders backend, mobile app and AI services.", + "openApiSpecifications": "./output/openapi-bundled.yaml", + "asyncSpecifications": "./output/asyncapi-bundled.yaml", + "arazzoSpecifications": "./api/specs/v1", + "asyncSourceRoot": "./api/specs/v1", + "dbContextClassname": "HelloOrdersDbContext", + "stateDir": "./.specfuse/state", + "tenancy": { + "tenant": { + "entity": "Tenant", + "foreignKey": "TenantId", + "marker": "ITenantOwned" + } + }, + "broker": { + "type": "azureServiceBus" + }, + "groups": [ + { + "name": "Domain", + "language": "C#", + "basePackage": "HelloOrders.Domain", + "destination": "../Backend/", + "cleanGenerated": true, + "artifacts": [ + "entity", + "valueObject", + "enum", + "apiController", + "apiModel" + ] + }, + { + "name": "AppCore", + "language": "dart", + "basePackage": "hello_orders_core", + "destination": "../hello_orders_app/packages/", + "cleanGenerated": true, + "cleanScope": [ + "hello_orders_core/lib/domain", + "hello_orders_core/lib/data" + ], + "artifacts": [ + "entity", + "enum", + "dto", + "dartApiClient", + "dartRepositoryInterface", + "dartRepositoryImpl" + ] + }, + { + "name": "Contracts", + "language": "python", + "basePackage": "hello_orders_contracts", + "destination": "../hello-orders-ai/libs/contracts/src", + "cleanGenerated": true, + "artifacts": [ + "entity", + "enum", + "event", + "asyncEventHandler" + ] + }, + { + "name": "Documentation - Technical References", + "language": "markdown", + "destination": "./docs/generated", + "artifacts": [ + "recipeDocumentation", + "entityDiagram", + "eventCatalog", + "channelTopology", + "docsIndex" + ] + } + ] + }, + "keys": { + "name": { + "sec": "21-name", + "kind": "identity", + "t": "Project name. Templates receive it and the generator derives defaults from it, such as the DbContext name.", + "req": "recommended" + }, + "description": { + "sec": "22-description", + "kind": "identity", + "t": "Free text. The generator does not read it.", + "req": "optional" + }, + "openApiSpecifications": { + "sec": "11-openapispecifications", + "kind": "specs", + "t": "The bundled OpenAPI file, not api/specs/v1/openapi.yaml. bundle-spec.sh (or the bundle skill) writes it to output/, so bundle before you generate or validate.", + "req": "required to generate" + }, + "asyncSpecifications": { + "sec": "12-asyncspecifications", + "kind": "specs", + "t": "The bundled AsyncAPI file, written by bundle-async-spec.sh. Leave it out if the project has no async specs.", + "req": "optional" + }, + "arazzoSpecifications": { + "sec": "13-arazzospecifications", + "kind": "specs", + "t": "A directory, read unbundled: every .arazzo.yaml and .recipe.yaml under it.", + "req": "optional" + }, + "asyncSourceRoot": { + "sec": "14-asyncsourceroot", + "kind": "specs", + "t": "The unbundled spec tree. Bundling erases file paths, so the generator reads this to find entity snapshots and check where they sit.", + "req": "optional" + }, + "dbContextClassname": { + "sec": "23-dbcontextclassname-c-only", + "kind": "identity", + "t": "Overrides the EF Core DbContext class name. C# only; other languages ignore it.", + "req": "optional · C#" + }, + "stateDir": { + "sec": "24-statedir", + "kind": "identity", + "t": "Where the generator keeps one baseline per group (.json). Each regen's CHANGELOG.md is diffed against it, so it must describe code that shipped on the component's main. Set it explicitly: when unset, its default depends on how the generator was invoked.", + "req": "optional, set it", + "warn": true + }, + "tenancy": { + "sec": "41-tenancy-c-only-today", + "kind": "runtime", + "t": "The tenant (and optional sub-tenant) entity, its foreign key and a marker interface. Drives C# marker-interface generation today.", + "req": "optional · C#" + }, + "tenancy.tenant": {"sec": "41-tenancy-c-only-today", "kind": "runtime", "t": "The tenant entity. A sub-tenant, tenancy.subTenant, takes the same three fields.", "req": "with tenancy"}, + "tenancy.tenant.entity": { + "sec": "41-tenancy-c-only-today", + "kind": "runtime", + "t": "PascalCase tenant entity name.", + "req": "with tenancy" + }, + "tenancy.tenant.foreignKey": { + "sec": "41-tenancy-c-only-today", + "kind": "runtime", + "t": "PascalCase foreign-key column, ending in Id.", + "req": "with tenancy" + }, + "tenancy.tenant.marker": { + "sec": "41-tenancy-c-only-today", + "kind": "runtime", + "t": "Marker interface name: I plus PascalCase.", + "req": "with tenancy" + }, + "broker": { + "sec": "51-broker", + "kind": "runtime", + "t": "Messaging-broker settings for the C# event runtime.", + "req": "optional · C#" + }, + "broker.type": { + "sec": "51-broker", + "kind": "runtime", + "t": "azureServiceBus is the only working value.", + "req": "optional" + }, + "groups": { + "sec": "71-groups", + "kind": "groups", + "t": "One entry per output tree. You generate one group at a time by name; generate.sh loops over several.", + "req": "required to generate" + }, + "groups[].name": { + "sec": "81-name", + "kind": "groups", + "t": "The label you pass to --group (and to generate.sh) and see in diagnostics.", + "req": "recommended" + }, + "groups[].language": { + "sec": "83-language", + "kind": "groups", + "t": "csharp / c#, python, dart / flutter, or markdown. Picks the template family and which artifacts are allowed.", + "req": "required" + }, + "groups[].basePackage": { + "sec": "85-basepackage", + "kind": "groups", + "t": "Namespace, package or library root for the generated code.", + "req": "required for code" + }, + "groups[].destination": { + "sec": "86-destination", + "kind": "groups", + "t": "Where the tree is written, resolved from the directory you run the generator in. ../Backend/ writes into a sibling repo, which is how components receive code: run from the spec repo root, with every repo checked out side by side.", + "req": "required for code", + "warn": true + }, + "groups[].cleanGenerated": { + "sec": "89-cleangenerated", + "kind": "groups", + "t": "Deletes the group's generated subtree before writing it again. Files outside that subtree are never touched, so keep hand-written code outside it.", + "req": "optional, default false", + "warn": true + }, + "groups[].cleanScope": { + "sec": "810-cleanscope", + "kind": "groups", + "t": "Exactly which folders to wipe, relative to destination, instead of the language default. List generated folders one by one; patterns are not supported and silently clean nothing.", + "req": "optional", + "warn": true + }, + "groups[].artifacts": { + "sec": "87-artifacts", + "kind": "groups", + "t": "Which artifact types to emit. An artifact not listed is not produced, and nothing warns. The allowed ids depend on the language.", + "req": "required for code" + } + }, + "more": [ + [ + "namingOverrides", + "31-namingoverrides", + "Identifier casing per role, project-wide." + ], + [ + "encryption", + "15-encryption", + "Compliance floors and key scope for x-protection. Absent means no floors apply and nothing warns." + ], + [ + "authorization", + "16-authorization", + "Roles or scopes mode and the claims the generated C# runtime reads." + ], + [ + "functionalTests", + "16-authorization", + "xUnit collection assignment for generated API functional tests." + ], + [ + "groups[].domains", + "8131-domains", + "Generate only some domains: exactly one of include or exclude." + ], + [ + "groups[].pillar", + "8132-pillar", + "Scope a group to the domains a pillar owns." + ], + [ + "groups[].filter", + "811-filter-asyncapi-workers-only-v1", + "Predicate tree for AsyncAPI workers only." + ], + [ + "persistence", + "6-persistence", + "Multi-backend persistence. Read by nothing today: declaring it changes nothing." + ] + ], + "footguns": [ + [ + "Unknown fields are silently ignored", + "A typo such as cleanScopes or tenancyConfig is dropped with no warning, and generation runs misconfigured. Copy field names from a known-good file and diff the output after every edit.", + "project-file" + ], + [ + "Paths resolve from where you run it", + "Run the generator from the spec repo root. From anywhere else, every relative path, destination included, points somewhere else.", + "working-directory-and-path-resolution" + ], + [ + "A regen writes into other repos, and deletes", + "With cleanGenerated, each group's generated tree is wiped first. Make sure every destination repo is on a branch you can throw away; generate.sh refuses dirty ones.", + "89-cleangenerated" + ], + [ + "Some blocks are inert", + "persistence is read by nothing yet, and an absent encryption block means no compliance floors, silently.", + "6-persistence" + ] + ], + "lifecycle": [ + [ + "Load", + "The JSON is read; unknown fields are dropped." + ], + [ + "Resolve paths", + "Relative paths against the working directory (CLI)." + ], + [ + "Validate", + "OpenAPI, AsyncAPI and Arazzo, through the spec paths, unless --skip-validation." + ], + [ + "Emit each group", + "Clean if asked, then write the group's artifacts to its destination." + ], + [ + "Record", + "Each group's surface goes to /.json, and CHANGELOG.md is written." + ], + [ + "AI access manifest", + "ai-access-manifest.json, once, beside the bundled OpenAPI file." + ] + ] + }, + + "components": { + "targets": [ + { + "group": "Domain", + "lang": "C#", + "repo": "Backend/", + "kind": "auto", + "gen": "gen-src/ folders inside each project; *.g.cs files with an header", + "ext": "partial classes in src/, generated *Base classes you derive from, mapper hook partials", + "build": "gen-src/ belongs to existing projects; Directory.Build.props compiles gen-src/ and src/ together. Exclude gen-src/ from coverage.", + "post": "None." + }, + { + "group": "AppCore", + "lang": "Dart / Flutter", + "repo": "hello_orders_app/", + "kind": "auto", + "gen": "A package owned entirely by the generator, e.g. packages/hello_orders_core", + "ext": "extension methods in the app's lib/; screens, view models and navigation stay in the app", + "build": "A path dependency in the app's pubspec.yaml.", + "post": "dart pub get, then dart run build_runner build --delete-conflicting-outputs inside the package. Put it in scripts/post-generate.sh so it runs after each regen." + }, + { + "group": "Contracts", + "lang": "Python", + "repo": "hello-orders-ai/", + "kind": "auto", + "gen": "_generated/ packages (the default wipe target)", + "ext": "your handler.py / composition.py beside the generated handler_base.py: subclass, don't patch", + "build": "Members of a uv workspace.", + "post": "None." + }, + { + "group": "Documentation - Technical References", + "lang": "Markdown", + "repo": "this spec repo", + "kind": "gen", + "gen": "docs/generated/ in the spec repo", + "ext": "nothing: regenerate instead of editing", + "build": "None.", + "post": "Prefer generate-scenario-docs.sh, which adds the scenario pages and cross-links." + } + ], + "regen": [ + { + "t": "Spec change merges", + "who": "human", + "body": "The spec PR merges in the spec repo first. A component PR generated from a spec that is not on main yet makes the component depend on a spec that does not exist." + }, + { + "t": "Regenerate on a branch", + "who": "auto", + "body": "From the spec repo root, with each destination repo on a fresh branch. generate.sh bundles once, then generates each named group, lists every destination with its repo and branch, and stops if one has uncommitted changes.", + "cmd": "./scripts/specfuse/generate.sh --list\n./scripts/specfuse/generate.sh Domain AppCore" + }, + { + "t": "One PR per component", + "who": "human", + "body": "Each component PR holds only the regen and the changes it requires. Review it beyond a green build: diff changed models by file name, enum values, operation parameters, and every hand-written type that mirrors a generated one." + }, + { + "t": "Component PRs merge", + "who": "human", + "body": "Commit generated code in the component, so every regen is a reviewable diff and CI builds without Java or generator access." + }, + { + "t": "Advance the baseline", + "who": "auto", + "body": "Only now, from a clean spec repo: advance-state.sh re-captures each group's stateDir baseline from the merged component code, and keeps it only for repos that are clean, on their default branch and level with origin. Commit the state files in a spec-repo PR of their own.", + "cmd": "./scripts/specfuse/advance-state.sh --dry-run\n./scripts/specfuse/advance-state.sh" + } + ], + "rules": [ + "Component repos never run the generator, fetch the jar or check out the specs.", + "Never hand-edit generated code. Fix the spec or report the generator, then regenerate.", + "Check the generated package before writing a model, DTO, repository or client by hand.", + "Never commit .specfuse/state from a spec branch or a local regen; it adds phantom Breaking entries to later changelogs." + ] + }, + + "loop": { + "frontends": [ + [ + "authored", + "The spec tree is the deliverable: work units author spec files. Used in the specs repo for spec-before-mint." + ], + [ + "consumed", + "Specs were authored upstream; the loop implements against a handoff manifest. Used in component repos." + ], + [ + "none", + "No specs: the task graph is authored directly. The short path for single-repo work." + ] + ], + "choices": [ + { + "q": "Author the specs for a shaped idea, more than one sitting of work", + "where": "Specs repo", + "front": "authored", + "steps": [ + "/specfuse:roadmap-add", + "/specfuse:pick-feature", + "/specfuse:draft-feature", + "specfuse run", + "/specfuse:arm-gate", + "/specfuse:wrap-feature", + "/specfuse-authoring:prepare-handoff" + ], + "body": "A loop feature gives spec authoring what a session does not: gated human review at /arm-gate, verification as the exit oracle, and a record per work unit. Record the feature in the dossier's graduated_to and the start date in started.", + "notes": [ + "Every work unit must leave the whole spec tree validating; one dangling $ref breaks the bundle and fails every layer.", + "A structural assertion block stands in for the red test, since a spec has no test to fail first.", + "Point the verification gates at ./scripts/specfuse/validate-specs.sh; /specfuse:derive-verification drafts them from what the repo runs." + ] + }, + { + "q": "Make a small or exploratory spec change", + "where": "Specs repo, no loop", + "front": null, + "steps": [ + "/specfuse-authoring:design-scenario", + "/specfuse-authoring:validate", + "commit and PR" + ], + "body": "A session with the design skills and the validators is enough when the change fits in one sitting. A gate cycle around a one-file change is ceremony without a checkpoint.", + "notes": [ + "The design skills ask, generate and validate in one flow; review-scenario gives a cold second opinion." + ] + }, + { + "q": "Implement a feature the orchestrator dispatched", + "where": "Component repo", + "front": "consumed", + "steps": [ + "/specfuse:adopt-feature", + "/specfuse:draft-feature", + "specfuse lint", + "specfuse run", + "/specfuse:arm-gate", + "/specfuse:wrap-feature" + ], + "body": "adopt-feature lists the repo's open specfuse:feature issues and scaffolds the one you pick, with its first work unit seeded from the issue body.", + "notes": [ + "Run adopt-feature interactively: it needs your pick.", + "One driver per working tree; concurrent features need separate git worktrees." + ] + }, + { + "q": "Implement against the specs without an orchestrator", + "where": "Component repo", + "front": "none", + "steps": [ + "regen PR merged", + "/specfuse:roadmap-add", + "/specfuse:pick-feature", + "/specfuse:draft-feature", + "specfuse run", + "/specfuse:arm-gate", + "/specfuse:wrap-feature" + ], + "body": "Regenerate first and merge that PR, so the feature builds on generated code that is already on main. The feature is then ordinary loop work in that repo, extending the generated code by hand.", + "notes": [ + "Keep the regen and the feature in separate PRs: the regen is reviewed as generated output, the feature as code." + ] + }, + { + "q": "Fix a bug", + "where": "Any repo, no feature", + "front": null, + "steps": [ + "/specfuse:fix-bug" + ], + "body": "One bug, one branch, one PR, test first. The skill refuses and proposes a feature if the work turns out to be large.", + "notes": [ + "A bug in generated code is a spec fix or a generator issue, not a hand edit: /specfuse-authoring:file-generator-issue." + ] + } + ], + "surfaces": [ + [ + "State lives in", + "WU and GATE file frontmatter", + "GitHub issue labels and the registry" + ], + [ + "Dispatch", + "the driver runs claude -p per work unit", + "inbox files and a polling loop" + ], + [ + "Branch and merge", + "one branch, one squashed commit per WU", + "a branch and PR per task, merge watcher" + ], + [ + "Spec front-end", + "authored, consumed or none", + "spec-first, from a dossier or a manifest" + ] + ] + }, + "tree": [ {"p": "CLAUDE.md", "d": 0, "o": "yours", "what": "Project instructions for Claude Code. Seeded once by init from the kit template.", "up": "Never touched by upgrade."}, {"p": "-project.json", "d": 0, "o": "yours", "what": "The generator's project file: where the bundles are, which artifact groups to generate, encryption profiles.", "up": "Never touched by upgrade.", "link": ["Project file handbook", "../handbooks/Project_File.md"]}, @@ -1092,7 +1656,7 @@

    Specfuse Authoring Flows

    const slash = n => `/specfuse-authoring:${n}`; const link = ([text, href]) => `${esc(text)}`; const nav = $("#nav"), main = $("#main"); - const ui = { mode: "orchestrated", sel: { kind: "state", m: "backlog", id: "ready" }, view: "picture", plane: "kit", life: "spec-first", stage: 0, node: 0, intent: null, query: "", mistake: 0, + const ui = { pfkey: "groups[].destination", tgt: 0, regen: 1, choice: 0, mode: "orchestrated", sel: { kind: "state", m: "backlog", id: "ready" }, view: "picture", plane: "kit", life: "spec-first", stage: 0, node: 0, intent: null, query: "", mistake: 0, sim: { area: "kit", ships: true, disk: "asWritten", manifest: true, version: "older" } }; function readHash() { @@ -1258,6 +1822,93 @@

    ${esc(edgeName(e))}

    ${esc(e.cond)}

    Green boxes end a machine; dashed boxes are side states. ${standalone() ? "Grey arrows need the orchestration repo. The red arrow is the gap: an ending the kit does not document yet." : "Switch to Without one to see what changes when no orchestration repo is configured."} These diagrams mirror ${link(["Unit lifecycles", D.machine.source])}, which also covers features, gates and work units; the rules for each backlog state are in the ${link(["backlog template", "../templates/initiative-backlog.template.md"])}.

    `; } + + /* ---------- Project file ---------- */ + function jsonHtml(v, path, ind) { + const pad = " ".repeat(ind); + if (Array.isArray(v)) { + if (v.every(x => typeof x !== "object")) return `${v.map(x => `${esc(JSON.stringify(x))}`).join('')}`; + return `\n${v.map(x => pad + " " + jsonHtml(x, path + "[]", ind + 1)).join('\n')}\n${pad}`; + } + if (v && typeof v === "object") { + const rows = Object.entries(v).map(([k, x]) => { + const kp = path ? `${path}.${k}` : k; + const info = D.projectFile.keys[kp]; + const key = info ? `` : ``; + return `${pad} ${key}${jsonHtml(x, kp, ind + 1)}`; + }); + return `\n${rows.join('\n')}\n${pad}`; + } + return `${esc(JSON.stringify(v))}`; + } + const PF = "../handbooks/Project_File.md"; + function viewProjectFile() { + const P = D.projectFile, k = P.keys[ui.pfkey]; + const kindLabel = { specs: "where the specs are", identity: "project", runtime: "generated runtime", groups: "output groups" }[k.kind]; + return `

    Project file

    +

    <project>-project.json is the generator's only input besides the specs: where the bundled specs are, and which code to generate into which folder. init seeds it with an empty groups list, and upgrade never touches it. The example generates into three component repos plus the spec repo's own docs. Select a key for what it does; orange keys are the ones that bite.

    +
    +
    ${jsonHtml(P.sample, "", 0)}
    +
    + ${esc(kindLabel)} +

    ${esc(ui.pfkey)}

    +

    ${esc(k.t)}

    +
    Required
    ${esc(k.req)}
    Read
    ${link(["Project_File.md", PF + "#" + k.sec])}
    +
    +
    +

    Four things to know before editing it

    ${P.footguns.map(([t, b, a]) => `
    ${esc(t)}${esc(b)} ${link(["More", PF + "#" + a])}
    `).join("")}
    +

    Not in the example

    ${P.more.map(([n, a, t]) => `
    ${esc(n)}${esc(t)} ${link(["§", PF + "#" + a])}
    `).join("")}
    +

    The generator version is not set here: specfuse authoring generate resolves it from the kit's pin. See ${link(["generator access", "https://github.com/specfuse/authoring/blob/main/docs/generator-access.md"])}.

    +

    What the generator does with it

      ${P.lifecycle.map(([t, b]) => `
    1. ${esc(t)}
      ${esc(b)}
    2. `).join("")}
    `; + } + + /* ---------- Specs and components ---------- */ + function viewComponents() { + const C = D.components, t = C.targets[ui.tgt], r = C.regen[ui.regen]; + return `

    Specs and components

    +

    One spec repo holds the specs, the project file and the generator's baselines. The component repos (a backend, an app, services) receive generated code through each group's destination, and build on it by hand. Generation always runs from the spec repo. Select a component, then walk the regen flow.

    +
    +
    Spec repoapi/specs/v1/<project>-project.json.specfuse/state/<group>.jsonscripts/specfuse/generate.sh
    + +
    ${C.targets.map((x, i) => ``).join("")}
    +
    +
    + ${esc(t.lang)} +

    ${esc(t.group)} → ${esc(t.repo)}

    +
    Generated code
    ${esc(t.gen)}
    Hand-written code
    ${esc(t.ext)}
    Build
    ${esc(t.build)}
    After generating
    ${esc(t.post)}
    +
    +

    The regen flow

    +

    The order matters: spec first, then components, then the baseline.

    +
    +
      ${C.regen.map((x, i) => `
    1. `).join("")}
    +
    ${r.who === "human" ? "you" : "a script does this"}

    ${esc(r.t)}

    ${esc(r.body)}

    ${r.cmd ? `${esc(r.cmd)}` : ""}
    +
    +

    House rules

      ${C.rules.map(x => `
    • ${esc(x)}
    • `).join("")}
    +

    The full guide, with build wiring per language and the review checklist: ${link(["Setting up component repos", "https://github.com/specfuse/authoring/blob/main/docs/component-repos.md"])}.

    `; + } + + /* ---------- The loop ---------- */ + function viewLoop() { + const Lp = D.loop, c = Lp.choices[ui.choice]; + return `

    The loop

    +

    The Specfuse loop runs a feature as gates of small work units: a driver dispatches each unit to a fresh session and accepts it only when verification passes, and you review at each gate. The kit authors and validates specs; the loop is how larger pieces of work get done under that discipline, in the spec repo or in a component. It comes with the same pipx install specfuse; specfuse init <repo> sets it up in a repo, and its own flows page lands at .specfuse/docs/flows.html.

    +
    ${Lp.frontends.map(([n, t]) => `
    spec front-end: ${esc(n)}${esc(t)}
    `).join("")}
    +
    I want to
    +
    +
      ${Lp.choices.map((x, i) => `
    • `).join("")}
    +
    + ${c.front ? "loop · " + esc(c.front) : "no loop feature"} +

    ${esc(c.where)}

    +

    ${esc(c.body)}

    +
    ${c.steps.map(x => `${esc(x)}`).join("→")}
    + ${c.notes.length ? `

    Keep in mind

      ${c.notes.map(x => `
    • ${esc(x)}
    • `).join("")}
    ` : ""} +
    +
    +

    Loop or orchestrator

    Both run the same gate cycle and work-unit contract. They differ in reach: the loop works inside one repo; the orchestrator spans repos and dispatches loop features into them.

    +
    ${Lp.surfaces.map(([a, b, c2]) => ``).join("")}
    Loop · one repoOrchestrator · many repos
    ${esc(a)}${esc(b)}${esc(c2)}
    +

    ${link(["Loop methodology", "https://github.com/specfuse/loop/blob/main/docs/methodology.md"])} · ${link(["Product lifecycle", "https://github.com/specfuse/specfuse/blob/main/docs/product-lifecycle.md"])}

    `; + } + /* ---------- Structure ---------- */ function viewStructure() { const n = D.tree[ui.node]; @@ -1392,7 +2043,7 @@

    ${esc(m.t)}

    Skills update separately

    Upgrade carries the contract the skills read. The skills and agents themselves come from the plugin: /plugin update specfuse-authoring@specfuse. Upgrade also refuses a downgrade, so install the newer CLI before upgrading a project someone else already moved forward.

    ${link(["Keep assets current", "https://github.com/specfuse/authoring/blob/main/docs/getting-started.md#7-keep-assets-current"])}

    `; } - const VIEWS = { picture: viewPicture, lifecycle: viewLifecycle, states: viewStates, structure: viewStructure, skills: viewSkills, validation: viewValidation, upgrade: viewUpgrade }; + const VIEWS = { picture: viewPicture, lifecycle: viewLifecycle, states: viewStates, projectfile: viewProjectFile, components: viewComponents, loop: viewLoop, structure: viewStructure, skills: viewSkills, validation: viewValidation, upgrade: viewUpgrade }; function render() { renderNav(); main.setAttribute("aria-labelledby", "tab-" + ui.view); @@ -1400,10 +2051,14 @@

    ${esc(m.t)}

    } main.addEventListener("click", e => { - const t = e.target.closest("[data-plane],[data-life],[data-stage],[data-node],[data-intent],[data-mistake],[data-sim],[data-state],[data-edge],[data-mode]"); + const t = e.target.closest("[data-plane],[data-life],[data-stage],[data-node],[data-intent],[data-mistake],[data-sim],[data-state],[data-edge],[data-mode],[data-pfkey],[data-tgt],[data-regen],[data-choice]"); if (!t) return; const d = t.dataset; - if (d.mode) { + if (d.pfkey) ui.pfkey = d.pfkey; + else if (d.tgt) ui.tgt = +d.tgt; + else if (d.regen) ui.regen = +d.regen; + else if (d.choice) ui.choice = +d.choice; + else if (d.mode) { ui.mode = d.mode; const cur = D.machine[ui.sel.m]; if (ui.sel.kind === "edge" && !cur.edges.some(x => x.id === ui.sel.id && edgeShown(x))) ui.sel = { kind: "state", m: "backlog", id: "ready" }; @@ -1421,7 +2076,7 @@

    ${esc(m.t)}

    ui.sim[d.sim] = v; if (!ui.sim.ships && ui.sim.disk === "same") ui.sim.disk = "asWritten"; } - const key = ["stage", "node", "plane", "state", "edge"].find(k => t.dataset[k] != null); + const key = ["stage", "node", "plane", "state", "edge", "pfkey", "tgt", "regen", "choice"].find(k => t.dataset[k] != null); const inSvg = !!t.closest("svg"); render(); if (key && (inSvg || (key !== "state" && key !== "edge"))) { diff --git a/scripts/check-flows-page.py b/scripts/check-flows-page.py index b4ca0ee..aa495eb 100644 --- a/scripts/check-flows-page.py +++ b/scripts/check-flows-page.py @@ -11,6 +11,9 @@ - backlog states against the State: line of the backlog template, and the idea state machine against those states - no-orchestrator table covers every skill + - project file example keys against Project_File.md's quick reference, + artifact ids against its per-language lists, and + every section anchor the page links - readiness checklist against the idea dossier template - validation layers against LAYERS in scripts/specfuse/validate-specs.sh - Spectral rule names against the kit's rulesets @@ -89,6 +92,62 @@ def same_set(label: str, page: set[str], source: set[str]) -> None: for name, status, _ in data["standalone"]: check(status in {"works", "degrades", "unavailable", "idle"}, f"no-orchestrator table: {name!r} has unknown status {status!r}") +# Project file: every key in the example is documented in Project_File.md's +# quick reference, every artifact id is one the handbook lists for that group's +# language, and every section anchor the page links to exists. +handbook = (ROOT / "handbooks" / "Project_File.md").read_text(encoding="utf-8") +quick = handbook.split("## Quick Reference", 1)[1].split("\n## ", 1)[0] +documented = {re.sub(r"(\[\])+$", "", m) for m in re.findall(r"^\| `([^`]+)`", quick, re.M)} + + +def flatten(obj: object, prefix: str = "") -> set[str]: + keys: set[str] = set() + if isinstance(obj, dict): + for k, v in obj.items(): + path = f"{prefix}.{k}" if prefix else k + keys.add(path) + keys |= flatten(v, path) + elif isinstance(obj, list): + for item in obj: + keys |= flatten(item, f"{prefix}[]") + return keys + + +pf = data["projectFile"] +sample_keys = flatten(pf["sample"]) +# An intermediate object (tenancy.tenant) counts when its fields are documented. +for k in sorted(k for k in sample_keys - documented if not any(d.startswith(k + ".") for d in documented)): + errors.append(f"project file example uses {k!r}, which Project_File.md's quick reference does not document") +same_set("project file key notes", set(pf["keys"]), sample_keys) +for k, _, _ in pf["more"]: + check(k in documented, f"project file 'not in the example' names {k!r}, which the quick reference does not document") + + +def slug(heading: str) -> str: + text = re.sub(r"[^\w\- ]", "", heading.strip().lower()) + return text.replace(" ", "-") + + +anchors = {slug(h) for h in re.findall(r"^#{1,4} (.+)$", handbook, re.M)} +cited = [k["sec"] for k in pf["keys"].values()] + [a for _, a, _ in pf["more"]] + [a for *_, a in pf["footguns"]] +for a in sorted(set(cited)): + check(a in anchors, f"project file view links Project_File.md#{a}, which is not a heading there") + +lang_sections = {"c#": "### 11.1", "csharp": "### 11.1", "python": "### 11.2", "dart": "### 11.3", "flutter": "### 11.3", "markdown": "### 11.5"} +for g in pf["sample"]["groups"]: + head = lang_sections.get(g["language"].lower()) + if not head: + errors.append(f"project file example group {g['name']!r} has a language the check cannot map: {g['language']!r}") + continue + body = handbook.split(head, 1)[1].split("\n### 11.", 1)[0] + for art in g.get("artifacts", []): + check(f"`{art}`" in body, f"project file example group {g['name']!r} lists artifact {art!r}, not in Project_File.md {head[4:]}") + +for ch in data["loop"]["choices"]: + for step in ch["steps"]: + if step.startswith("/specfuse-authoring:"): + check(step.split(":", 1)[1] in skill_dirs, f"loop choice {ch['q']!r} names unknown skill {step!r}") + # Backlog states: "State: `idea` → `shaping` → … (or `delivered` / `parked` / `dropped`)". backlog = (ROOT / "templates" / "initiative-backlog.template.md").read_text(encoding="utf-8") state_line = next((para.replace("\n", " ") for para in backlog.split("\n\n") if para.startswith("State: `")), "") From ca5e1d2a41c1b73a536497d34e662faf76476d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Mon, 19 Oct 2026 08:30:12 -0400 Subject: [PATCH 4/4] docs(flows): keep the detail panel in view while scrolling a long list Co-Authored-By: Claude Opus 5.5 --- docs/flows.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/flows.html b/docs/flows.html index 61ccf4b..4240f79 100644 --- a/docs/flows.html +++ b/docs/flows.html @@ -300,6 +300,10 @@ table.surf th { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } .foot { margin-top: 40px; font-size: 13px; color: var(--muted); max-width: 80ch; } +/* Detail panels follow the scroll beside long lists; on phones they stack below instead. */ +@media (min-width: 861px) { + .board > .detail, .finder > div:last-child { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 12px); max-height: calc(100vh - 24px); overflow-y: auto; } +} @media (max-width: 980px) { .chain { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chain .node:nth-child(2n)::after { display: none; }