From f8ce4717a86644cb61290c27f80179e2c375be9c Mon Sep 17 00:00:00 2001
From: tegwick
Date: Tue, 29 Sep 2026 14:12:21 +0200
Subject: [PATCH] Projects have their own name, description and goal; the
template is shown only as origin
Co-Authored-By: Claude Sonnet 5.5
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
---
docs/maturity-and-projects.md | 7 ++++---
projects/trial-1/project.yaml | 30 ++++++++++++++++++++++++++++++
rtut/__main__.py | 7 +++++--
rtut/project.py | 27 +++++++++++++++++++++------
rtut/ui_projects.py | 23 ++++++++++++++++++-----
schema/project.schema.json | 4 ++++
tests/test_projects.py | 18 ++++++++++++++++--
tests/test_ui_projects.py | 33 +++++++++++++++++++++++++++------
8 files changed, 125 insertions(+), 24 deletions(-)
create mode 100644 projects/trial-1/project.yaml
diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md
index 3ee03d8..e0e95e4 100644
--- a/docs/maturity-and-projects.md
+++ b/docs/maturity-and-projects.md
@@ -161,9 +161,10 @@ member status `not-applied | applying | applied | stale | failed`).
### Templates
-A **project template** (`spec: project-template/v0.1`) is a project skeleton: goal, members, attribute sets and attributes that
-still need values. Instantiating a template creates a project file, records which template it came from, and shows which required
-attributes are still needed. Templates make a set of runbooks reusable as a product.
+A **project template** (`spec: project-template/v0.1`) is a project skeleton: members, attribute sets and attributes that
+still need values. Instantiating a template creates a project file with **its own name, description and goal**, records which template it
+came from, and shows which required attributes are still needed. Nothing of the template's own title, goal or description is copied into the
+project: a project is not a copy of its template, it only remembers where it came from. Templates make a set of runbooks reusable as a product.
### Modes
diff --git a/projects/trial-1/project.yaml b/projects/trial-1/project.yaml
new file mode 100644
index 0000000..5c1977a
--- /dev/null
+++ b/projects/trial-1/project.yaml
@@ -0,0 +1,30 @@
+spec: project/v0.1
+id: trial-1
+title: A watch at a lighthouse
+goal: The night watch is played through and its logbook entry is written and removed
+ again.
+attribute_sets:
+- harbour-defaults
+targets:
+ north-light:
+ title: The northern light
+ attributes:
+ weather: fog
+ south-light:
+ title: The southern light
+ attributes:
+ weather: clear
+runbooks:
+- pack: rtut.showcase-lighthouse
+ target: north-light
+- pack: rtut.example-hello
+ after:
+ - rtut.showcase-lighthouse
+environment: rehearsal
+template: lighthouse-watch
+attributes:
+ logdir:
+ requirement: optional
+ note: Scratch folder; must start with /tmp/rtut-
+ value: /tmp/rtut-trial-1
+ keeper: Dana
diff --git a/rtut/__main__.py b/rtut/__main__.py
index 281ceea..cdb59f7 100644
--- a/rtut/__main__.py
+++ b/rtut/__main__.py
@@ -78,8 +78,8 @@ def _project(a) -> int:
print(f"{tid}\ttemplate\t-\t{d.data.get('title', '')}")
return 0
if a.action == "new":
- path = J.instantiate(reg, a.name, a.extra, a.environment,
- dict(kv.split("=", 1) for kv in a.set))
+ path = J.instantiate(reg, a.name, a.extra, a.environment, dict(kv.split("=", 1) for kv in a.set),
+ title=a.title, description=a.description, goal=a.goal)
print(path); return 0
d = reg.projects.get(a.name)
if d is None:
@@ -154,6 +154,9 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--environment", "-e", default="")
sp.add_argument("--set", "-s", action="append", default=[], help="new: attribute=value")
sp.add_argument("--operator", "-o", default="")
+ sp.add_argument("--title", default=None, help="new: the project's own name (default: its id)")
+ sp.add_argument("--description", default="", help="new: what the project is")
+ sp.add_argument("--goal", default="", help="new: what should be true when it is done")
sp.add_argument("--target", default=None)
sp.add_argument("--mode", default="manual", choices=["manual", "auto-confirm"])
sp = sub.add_parser("score", help="how often each runbook was tried and how it went")
diff --git a/rtut/project.py b/rtut/project.py
index 3b82fb2..57c2b0f 100644
--- a/rtut/project.py
+++ b/rtut/project.py
@@ -212,8 +212,13 @@ def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str
# ---- templates -----------------------------------------------------------------------------------------------
def instantiate(reg: Registry, template_id: str, project_id: str, environment: str,
- values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None) -> Path:
- """Write projects//project.yaml from a template. Required attributes may stay unset; `state()` lists them."""
+ values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None,
+ description: str = "", goal: str = "") -> Path:
+ """Write projects//project.yaml from a template. Required attributes may stay unset; `state()` lists them.
+
+ The project has its own name, description and goal. Nothing of the template's own title, goal or description
+ is copied: the project only records which template it came from.
+ """
t = reg.templates.get(template_id)
if t is None or not t.ok:
raise ValueError(f"no valid template {template_id}")
@@ -221,7 +226,11 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s
raise ValueError("project id must be lower-case letters, digits, dots and hyphens")
if environment not in R.ENVIRONMENTS:
raise ValueError(f"environment must be one of {', '.join(R.ENVIRONMENTS)}")
- d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title")}
+ from .pack import SECRET_RE
+ for label, text in (("title", title or ""), ("description", description), ("goal", goal)):
+ if SECRET_RE.search(text):
+ raise ValueError(f"the {label} looks like a secret; projects are never secret")
+ d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title", "goal", "description")}
attrs = normalize(d.get("attributes"))
for k, v in (values or {}).items():
attrs.setdefault(k, {"value": None, "requirement": None})
@@ -230,9 +239,15 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s
for k, a in attrs.items():
out_attrs[k] = a["value"] if a.get("value") is not None and not a.get("requirement") and not a.get("note") else \
{kk: vv for kk, vv in a.items() if vv is not None}
- project = {"spec": "project/v0.1", "id": project_id, "title": title or t.data["title"],
- **{k: v for k, v in d.items() if k not in ("attributes", "environment")}, "environment": environment,
- "template": template_id}
+ if len((title or project_id).strip()) < 3:
+ raise ValueError("the project name needs at least 3 characters")
+ project = {"spec": "project/v0.1", "id": project_id, "title": (title or project_id).strip()}
+ if description.strip():
+ project["description"] = description.strip()
+ if goal.strip():
+ project["goal"] = goal.strip()
+ project.update({k: v for k, v in d.items() if k not in ("attributes", "environment")})
+ project.update({"environment": environment, "template": template_id})
if out_attrs:
project["attributes"] = out_attrs
home = Path(root) if root else t.path.parent.parent
diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py
index 249043f..344b3db 100644
--- a/rtut/ui_projects.py
+++ b/rtut/ui_projects.py
@@ -68,8 +68,10 @@ def index_sections(app) -> str:
continue
errs = J.validate(reg, d)
st = pill("invalid") if errs else pill(J.state(reg, d)["state"])
- prj.append(f"
")
# what is still needed
needs: dict[str, list[str]] = {}
for n in st["needs"]:
@@ -204,6 +208,12 @@ def template_page(app, tid: str, msg: str = "") -> bytes | None:
envs = "".join(f"" for x in R.ENVIRONMENTS)
out.append("