Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,15 @@ jobs:
# guard is kept: the tag, the release and the asset below do not need
# master, so the site sees the release as soon as this step ends. The
# `+` lets a re-dispatch replace the branch; nothing else writes to it.
# The runner's global pre-push hook caps a pushed branch at 1000
# lines because a branch becomes a PR a person reads. This snapshot
# is not authored work: v0.8.0 was 64857 lines against the public
# master (run 36429780866, 2026-09-28) and the hook refused it, so
# nothing reached the public repository. The override is the hook's
# own, per push, the same one bin/quiet-corners-refresh.sh and
# bin/gms-journal-cron.sh already use for their pipeline pushes.
BRANCH="release/$TAG"
git push origin "+HEAD:refs/heads/$BRANCH"
DIFF_CAP_OVERRIDE=1 git push origin "+HEAD:refs/heads/$BRANCH"
# A tag already on the public repository is never moved: a re-dispatch
# reuses it, and its tree is the same `git archive` of the same tag.
if [ -z "$(git ls-remote --tags origin "refs/tags/$TAG")" ]; then
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ fragments into this file at release time.

## [Unreleased]

## [0.9.0] - 2026-10-08

### Changed
- `requirements.lock` follows the grouped pip bump it constrains: alembic to
1.20.0, psycopg and psycopg-binary to 3.3.6, sqlalchemy to 2.0.54 and uvicorn
to 0.53.0. Dependabot compiles `requirements-runtime.txt` and leaves the
constraint file behind, so the two named different versions and
`tests/test_image_dependency_parity.py` failed on the bump.
- The static showcase's notice is now a sticky return bar on every demo page:
a link back to headlessmode.com, the "Fixed fictional demo" label, a link to
the driftless product page, and an "About this demo" disclosure holding the
source commit and export date. Both links are absolute, so the bar works
wherever the bundle is hosted, and headings keep a scroll margin so fragment
links are not hidden under it. `--home-url` sets the studio link.

### Fixed
- The receipt footer's 64-character sha256 now wraps, so the project hub and status pages no longer scroll sideways at 390px.
- The Scope & Baseline document now honours its as-of date: baselines approved and change requests raised after it no longer appear, and an approved baseline with no recorded approval date is flagged "not recorded" instead of shown as dated history.
- The read-only showcase export no longer shows instructions that only make sense beside a form or the write API ("Use the form below", `POST /...`, `PATCH /...`, "Try a crash"); templates mark them with `data-showcase="write"` and a contract test keeps new ones marked.
- The artifact reference no longer says task links, requirement traces, the resource tree, calendars, equipment/material resources or deliverable verification are "not tracked" when the product records them.
- The methods page labels "fully crosswalked" as Driftless's own mapping, not identity with or certification against the standard.
- The read-only showcase export no longer shows the dashboard's empty-state API calls or the organization configuration's "Next action" column; both are marked `data-showcase="write"`, and the marker now covers table cells.
- The Scrum sprint-planning agenda no longer invites exceptions to the Definition of Done (The Scrum Guide, 2020: an item that misses it returns to the Product Backlog).

## [0.8.0] - 2026-09-27

### Added
Expand Down
47 changes: 40 additions & 7 deletions bin/driftless-showcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
)
SCRIPT = re.compile(r"<script\b[^>]*>.*?</script>", re.IGNORECASE | re.DOTALL)
FORM = re.compile(r"<form\b[^>]*>.*?</form>", re.IGNORECASE | re.DOTALL)
# Prose that only makes sense beside a form or the write API ("Use the form below",
# "POST /risks ...") is dead once the controls are gone. Templates mark it with
# ``data-showcase="write"`` on the smallest element holding it; marked elements never
# nest (tests/test_showcase_demo.py asserts that), so the lazy match to the same
# closing tag is exact.
WRITE_ONLY = re.compile(
r'<(span|p|li|ol|h2|th|td)\b[^>]*\bdata-showcase="write"[^>]*>.*?</\1>',
re.IGNORECASE | re.DOTALL,
)
# The method map's filter strip is driven by static/map.js alone; with every script
# stripped it would be a row of dead checkboxes and a Reset button that resets nothing.
MAP_FILTERS = re.compile(r'<div class="map-filters">.*?</div>', re.DOTALL)
Expand Down Expand Up @@ -413,9 +422,16 @@ def _rewrite_anchors(body: str, files: dict[str, frozenset[str]], snapshot: Modu
#: so the URL is not hard-coded twice between the CLI default and the notice.
DEFAULT_SITE_URL = "https://headlessmode.com/driftless/"

#: The studio home the return bar's first link points at (``--home-url``).
DEFAULT_HOME_URL = "https://headlessmode.com/"


def transform_bundle(
pages: dict[str, str], anchor: str, source: str, site_url: str = DEFAULT_SITE_URL
pages: dict[str, str],
anchor: str,
source: str,
site_url: str = DEFAULT_SITE_URL,
home_url: str = DEFAULT_HOME_URL,
) -> dict[str, str]:
"""Remove active behavior, label the fixture, and make page links portable."""
snapshot = _snapshot_module()
Expand All @@ -425,13 +441,26 @@ def transform_bundle(
files = {name: frozenset(ELEMENT_ID.findall(body)) for name, body in kept.items()}
label = (
'<aside class="showcase-notice" role="note">'
f'<a href="{html.escape(site_url)}">&larr; Back to headlessmode.com/driftless</a> '
f"&middot; <strong>Fixed fictional demo</strong> &middot; as of "
f"{html.escape(anchor)}</aside>"
f'<a class="showcase-home" href="{html.escape(home_url)}">&larr; Headless Mode</a> '
'<span class="showcase-title">driftless &middot; Read-only demo &middot; '
f"<strong>Fixed fictional demo</strong> &middot; as of {html.escape(anchor)}</span> "
f'<a class="showcase-product" href="{html.escape(site_url)}">Product details</a> '
'<details class="showcase-about"><summary>About this demo</summary>'
"<p>Every name, date and figure here is fictional, and nothing on these pages can be "
f"changed. Exported from driftless source {html.escape(source)}, "
f"as of {html.escape(anchor)}.</p></details></aside>"
)
style = (
"<style>.showcase-notice{border:2px solid var(--focus);padding:.65rem .8rem;"
"margin:0 0 1rem;background:var(--muted-bg);font-size:.85rem}"
"<style>.showcase-notice{position:sticky;top:0;z-index:50;display:flex;"
"flex-wrap:wrap;align-items:center;gap:.1rem .9rem;box-sizing:border-box;"
"border-bottom:2px solid var(--focus);padding:.3rem .8rem;margin:0 0 1rem;"
"background:var(--muted-bg);font-size:.85rem;line-height:1.4}"
".showcase-title{flex:1 1 auto}"
".showcase-about{position:relative}"
".showcase-about p{position:absolute;right:0;top:100%;width:min(22rem,86vw);"
"margin:.3rem 0 0;padding:.6rem .8rem;border:2px solid var(--focus);"
"background:var(--muted-bg)}"
":target,h1,h2,h3,h4{scroll-margin-top:3.5rem}"
".showcase-unavailable{color:var(--muted-ink)}"
".showcase-intro{border:1px solid var(--rule);border-radius:.4rem;"
"padding:.65rem .8rem;margin:0 0 1rem;background:var(--muted-bg)}"
Expand All @@ -454,6 +483,7 @@ def transform_bundle(
body = MANIFEST_LINK.sub("", body)
body = PRINT_LINK.sub("", body)
body = FORM.sub("", body)
body = WRITE_ONLY.sub("", body)
body = MAP_FILTERS.sub("", body)
body = MAP_ZOOM.sub("", body)
body = LOGIN_LINK.sub("", body)
Expand Down Expand Up @@ -691,13 +721,16 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--source", default=None)
parser.add_argument("--project", default="Season 4 Rollout")
parser.add_argument("--site-url", default=DEFAULT_SITE_URL)
parser.add_argument("--home-url", default=DEFAULT_HOME_URL)
args = parser.parse_args(argv)
if args.source is None:
args.source = _default_source(repo_root)
print(f"--source not given; defaulting to {args.source!r} (checkout at {repo_root})")
snapshot = _snapshot_module()
pages = render_showcase(snapshot, args.anchor, args.project)
transformed = transform_bundle(pages, args.anchor.isoformat(), args.source, args.site_url)
transformed = transform_bundle(
pages, args.anchor.isoformat(), args.source, args.site_url, args.home_url
)
count = write_bundle(args.out, transformed, args.anchor.isoformat(), args.source)
print(f"Showcase: {count} read-only pages at {args.anchor} -> {args.out}")
return 0
Expand Down
9 changes: 9 additions & 0 deletions docs/temporal-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ runs on SQLite and a deployment runs Postgres, and the one CI job that speaks
Postgres runs the migration tests and no application query. It also leaves the
column bare, so an index on it stays usable.

**Scope & Baseline document — closed.** `report/documents/scope_baseline.py`
selected every approved baseline and every change request regardless of date, so a
later approval repainted an earlier as-of. It now gates baselines with
`adapters.approved_as_of` (inclusive of the whole as-of day) and change requests on
`raised_on <= as_of`. An approved row with no `approved_at` stays visible, as in
`plan_baseline`, but is rendered "not recorded" with a note, never as dated fact.
A change request's status has no history, so it shows the current status; only the
produced version is gated. Full status history is a separate decision.

## Effective time boundary

Two kinds of date live in this schema. **Effective (business) time** is when
Expand Down
2 changes: 1 addition & 1 deletion driftless/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
never copied into it, so numbers cannot drift between a screen and a report.
"""

__version__ = "0.8.0"
__version__ = "0.9.0"
2 changes: 1 addition & 1 deletion driftless/pmbok/artifact_content/deliverables.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"verified_deliverables": ArtifactContent(
plain_summary="Deliverables that have actually been checked to make sure they meet requirements.",
why_it_matters="Checking a deliverable before calling it done catches problems while they're still cheap to fix.",
what_it_looks_like_here="Not tracked: verification isn't recorded — read task status and completion percentage instead.",
what_it_looks_like_here="Recorded as the verified date on a deliverable's entry in the project's sign-off ledger.",
),
"accepted_deliverables": ArtifactContent(
plain_summary="Deliverables the customer or sponsor has formally signed off as satisfactory.",
Expand Down
10 changes: 5 additions & 5 deletions driftless/pmbok/artifact_content/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"project_schedule_network_diagram": ArtifactContent(
plain_summary="A picture of which pieces of work have to happen before others can start.",
why_it_matters="It shows where a delay in one place will ripple into delays somewhere else.",
what_it_looks_like_here="Not tracked: tasks here carry no recorded links to each other, so there is no diagram to draw.",
what_it_looks_like_here="Recorded as dependencies between tasks (finish-to-start and the other link kinds, with lag); the schedule assistant draws them as a network with float and the critical path.",
),
"duration_estimates": ArtifactContent(
plain_summary="How long each piece of work is expected to take.",
Expand Down Expand Up @@ -107,7 +107,7 @@
"requirements_traceability_matrix": ArtifactContent(
plain_summary="A table linking each need back to the piece of work that satisfies it.",
why_it_matters="It lets anyone check that every requirement is actually covered by something, not dropped.",
what_it_looks_like_here="Not tracked: there is no individual requirement row to trace, so the approved scope baseline stands in for it.",
what_it_looks_like_here="Each requirement is a recorded row, and each trace links it to one deliverable, task or backlog item; the requirements assistant lists the untraced ones.",
),
"project_scope_statement": ArtifactContent(
plain_summary="A written description of exactly what is, and is not, included in the project.",
Expand Down Expand Up @@ -157,12 +157,12 @@
"resource_breakdown_structure": ArtifactContent(
plain_summary="A tree that organizes the project's people and resources into groups.",
why_it_matters="It makes it easier to see where capacity is thin before it becomes a crisis.",
what_it_looks_like_here="Not tracked as its own tree: people here sit directly under departments instead.",
what_it_looks_like_here="Recorded as a tree of resource-type quantities per project (people, equipment and material); people also sit under departments.",
),
"resource_calendars": ArtifactContent(
plain_summary="When each specific person or resource is actually available to work.",
why_it_matters="A plan that ignores who is actually free will keep assigning work to people who are already busy.",
what_it_looks_like_here="Not tracked as a calendar record: capacity here is shown as weekly hours instead.",
what_it_looks_like_here="No per-person calendar: each project has a working-day calendar with dated exceptions, and a person's capacity is shown as weekly hours.",
),
"team_charter": ArtifactContent(
plain_summary="A short written agreement on how the team will work together.",
Expand All @@ -179,7 +179,7 @@
"physical_resource_assignments": ArtifactContent(
plain_summary="A record of which equipment, materials or other non-person resources are assigned to the work.",
why_it_matters="Work that needs equipment nobody has reserved tends to stall waiting for it.",
what_it_looks_like_here="Not tracked: people are the only kind of resource this system follows.",
what_it_looks_like_here="Equipment and material are resource types in the project's resource tree, and acquisitions record how each is sourced; there is no per-item assignment to a task.",
),
"stakeholder_register": ArtifactContent(
plain_summary="A list of everyone with a stake in the project, and what matters to them.",
Expand Down
3 changes: 2 additions & 1 deletion driftless/pmbok/method_content/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class MethodContentBlock:
"Walk the top of the Product Backlog and confirm each item is ready to pull.",
"The Developers select the work they believe they can finish this Sprint.",
"Break selected items into a plan of concrete tasks for the Sprint Backlog.",
"Confirm the Definition of Done applies unchanged, or note any exceptions.",
"Confirm the Definition of Done every selected item must meet; an item that misses it "
"goes back to the Product Backlog.",
"Restate the finished Sprint Goal and the Sprint Backlog back to the room.",
),
),
Expand Down
26 changes: 23 additions & 3 deletions driftless/report/documents/scope_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
subtraction (the cost delta) is done here; the template only formats. Every
collection is ordered for byte-identical output.

As-of contract: only baselines whose ``approved_at`` falls on or before ``as_of``
(inclusive of the whole day) and change requests raised on or before it appear, so
a later approval or request never repaints an earlier date. A legacy approved row
with no ``approved_at`` stays visible — the same null policy as
``adapters.plan_baseline`` — but is listed as "not recorded" with a note, never
presented as a dated fact. A change request's own status has no history, so its
status column is the current one; only the produced version is gated.

A project with no approved baseline has nothing to compare, so ``render`` returns
a short placeholder document (the template's empty-``versions`` branch) instead of
indexing an empty list — this keeps ``report all`` completing for every project."""
Expand All @@ -15,6 +23,7 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload

from driftless.assess.adapters import approved_as_of
from driftless.models import Baseline, BaselineLine, ChangeRequest, Project
from driftless.report import engine

Expand Down Expand Up @@ -57,12 +66,18 @@ def render(session: Session, project: Project, as_of: date) -> str:
"""Render the Scope & Baseline document for ``project`` as of ``as_of``."""
baselines = session.scalars(
select(Baseline)
.where(Baseline.project_id == project.id, Baseline.status == "approved")
.where(
Baseline.project_id == project.id,
Baseline.status == "approved",
approved_as_of(as_of),
)
.order_by(Baseline.version)
).all()
versions = [
{"version": b.version, "status": b.status, "approved_at": b.approved_at} for b in baselines
]
undated = [f"v{b.version}" for b in baselines if b.approved_at is None]
visible_ids = {b.id for b in baselines}
if not baselines:
return engine.render(
"scope_baseline.md",
Expand All @@ -75,11 +90,15 @@ def render(session: Session, project: Project, as_of: date) -> str:
"raised_on": cr.raised_on,
"description": cr.description,
"status": cr.status,
"version": cr.resulting_baseline.version if cr.resulting_baseline is not None else None,
"version": (
cr.resulting_baseline.version
if cr.resulting_baseline is not None and cr.resulting_baseline.id in visible_ids
else None
),
}
for cr in session.scalars(
select(ChangeRequest)
.where(ChangeRequest.project_id == project.id)
.where(ChangeRequest.project_id == project.id, ChangeRequest.raised_on <= as_of)
.order_by(ChangeRequest.raised_on, ChangeRequest.id)
)
]
Expand All @@ -88,6 +107,7 @@ def render(session: Session, project: Project, as_of: date) -> str:
"project": project.name,
"as_of": as_of,
"versions": versions,
"undated": undated,
"original": original,
"current": current,
"changed": len(baselines) > 1,
Expand Down
5 changes: 4 additions & 1 deletion driftless/report/templates/scope_baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ No approved baseline recorded.
| Version | Status | Approved |
| --- | --- | --- |
{% for v in versions %}
| v{{ v.version }} | {{ v.status }} | {{ v.approved_at if v.approved_at is not none else "—" }} |
| v{{ v.version }} | {{ v.status }} | {{ v.approved_at if v.approved_at is not none else "not recorded" }} |
{% endfor %}
{% if undated %}
_Approval date not recorded for {{ undated|join(", ") }}; treated as in force at every as-of, not confirmed as history._
{% endif %}

{% if changed %}
## Original (v{{ original.version }}) — planned cost {{ "%.2f"|format(original.total) }}
Expand Down
4 changes: 3 additions & 1 deletion driftless/web/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ def method_index(request: Request) -> HTMLResponse:

The coverage count above the list splits the same rows by the model's own
crosswalk vocabulary: a method is "fully crosswalked" only if every one of
its practices names a PMBOK-6 process or technique (``Practice.crosswalk``);
its practices names a PMBOK-6 process or technique (``Practice.crosswalk``) --
Driftless's own mapping of related practices, not identity with, or
certification against, the standard;
otherwise it carries at least one practice whose crosswalk is deliberately
empty and excused by a ``crosswalk_reason`` — "guide-only" here.
"""
Expand Down
Loading
Loading