From 419794247574a87b343ab46207ae804469742357 Mon Sep 17 00:00:00 2001 From: achuvyas-kv Date: Tue, 18 Aug 2026 10:12:24 +0530 Subject: [PATCH 1/2] Add stage-artifact publishing: rendered HTML per stage, shareable links on Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each SDLC stage already writes a canonical .md (prd/hld/lld/review). This surfaces those as rendered pages the user can read, not just paths — a shareable link on Claude Code, a local HTML file on Cursor/Codex. - engine/render_doc.py: stdlib-only, deterministic Markdown -> self-contained HTML fragment (publish-ready AND locally openable). Covers the SDLC md subset. - engine/artifact_record.py: owns .maestro/runs//artifacts.json, the step-id -> {file,url} map, so a revised stage updates the SAME link instead of minting a new one. Path-only harnesses never wipe a link a Claude Code run set. - skills/maestro: after `complete` on a step with a .md artifact, render then publish harness-aware. Presentation only — never gates a step, never reads the doc into the lead agent's context (rule 2 preserved). No workflow-graph edits (no back-edge/cascade risk); no schema changes. Rendering is a skill-driven standing behavior, so it auto-covers every current and future doc stage. 12 new tests; full suite green (181). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 8 + engine/artifact_record.py | 123 ++++++++++++ engine/render_doc.py | 280 +++++++++++++++++++++++++++ engine/tests/test_artifact_record.py | 76 ++++++++ engine/tests/test_render_doc.py | 108 +++++++++++ skills/maestro/SKILL.md | 48 +++++ 6 files changed, 643 insertions(+) create mode 100644 engine/artifact_record.py create mode 100644 engine/render_doc.py create mode 100644 engine/tests/test_artifact_record.py create mode 100644 engine/tests/test_render_doc.py diff --git a/CLAUDE.md b/CLAUDE.md index 7b4d024..38ca6c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,14 @@ the user's interactive session (Claude Code, Cursor, Codex). Conductor is gone. - **`oq_serve.py` / `oq_record.py`** — the open-questions `script`-node helpers (the stdout-JSON-becomes-routable-outputs pattern); `validate_tasks.py` / `validate_open_questions.py` — standalone artifact-format validators. +- **`render_doc.py` / `artifact_record.py`** — stage-artifact publishing. `render_doc.py` + turns a stage's `.md` (PRD/HLD/LLD/review) into one self-contained, theme-aware HTML + fragment (stdlib-only markdown→HTML, deterministic, publish-ready AND locally openable). + `artifact_record.py` owns `.maestro/runs//artifacts.json` — the step-id→{file,url} + map so a revised stage updates the SAME shareable link. The lead agent renders on every + agent-step `complete` with a `.md` artifact, then publishes harness-aware: Claude Code → + Artifact link (updated in place via the stored url); Cursor/Codex → the local `.html` + file. Presentation only — never gates a step, never read into the agent's context. - **`workspace_sync.py`** — parallel current-upstream fetch/status, tamper-evident fast-forward-only apply, living-doc commit provenance (`.maestro/index/`), and per-feature exact-SHA locks. It owns sync safety; the knowledge skill owns only doc-writing judgement. diff --git a/engine/artifact_record.py b/engine/artifact_record.py new file mode 100644 index 0000000..1044086 --- /dev/null +++ b/engine/artifact_record.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""artifact_record.py — the per-run manifest of published stage artifacts. + +The lead agent (skills/maestro) publishes a rendered stage doc and gets back a shareable +URL (Claude Code) or just a file path (Cursor / Codex). To make a *revision* update the +SAME link instead of minting a new one, that mapping has to persist across the run — and +per the engine's rules it must be written by deterministic engine code, not by the LLM and +not into state.yaml. This owns `.maestro/runs//artifacts.json`. + + get + Print {"key","file","url","title"} for (url/file "" if never recorded). + The agent reads `url` and passes it back to the publisher so the link updates in place. + + record --file F [--url U] [--title T] + Upsert the mapping for . Print the stored record. + + list + Print the whole manifest. + +Stdlib-only, atomic write (tmp + rename), deterministic (no timestamps) so it is +golden-set testable like the rest of the engine. +""" +import json +import os +import sys + +VERSION = 1 + + +def _path(run_dir): + return os.path.join(run_dir, "artifacts.json") + + +def load(run_dir): + p = _path(run_dir) + if not os.path.exists(p): + return {"version": VERSION, "artifacts": {}} + with open(p, "r", encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict) or not isinstance(data.get("artifacts"), dict): + raise ValueError(f"malformed manifest: {p}") + data.setdefault("version", VERSION) + return data + + +def save(run_dir, data): + os.makedirs(run_dir, exist_ok=True) + p = _path(run_dir) + tmp = p + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, sort_keys=True) + fh.write("\n") + os.replace(tmp, p) + + +def get(run_dir, key): + rec = load(run_dir)["artifacts"].get(key, {}) + return { + "key": key, + "file": rec.get("file", ""), + "url": rec.get("url", ""), + "title": rec.get("title", ""), + } + + +def record(run_dir, key, file="", url="", title=""): + data = load(run_dir) + cur = data["artifacts"].get(key, {}) + # A record with no fresh url keeps any url already on file (path-only harnesses must not + # wipe a link a previous Claude Code run established for the same stage). + rec = { + "file": file or cur.get("file", ""), + "url": url or cur.get("url", ""), + "title": title or cur.get("title", ""), + } + data["artifacts"][key] = rec + save(run_dir, data) + out = {"key": key} + out.update(rec) + return out + + +def main(argv): + if len(argv) < 2: + sys.stderr.write(__doc__.split("\n\n")[2] + "\n") + return 2 + cmd, run_dir = argv[0], argv[1] + rest = argv[2:] + try: + if cmd == "get": + if not rest: + sys.stderr.write("get needs \n") + return 2 + print(json.dumps(get(run_dir, rest[0]))) + return 0 + if cmd == "list": + print(json.dumps(load(run_dir))) + return 0 + if cmd == "record": + if not rest: + sys.stderr.write("record needs \n") + return 2 + key = rest[0] + opts = rest[1:] + vals = {"--file": "", "--url": "", "--title": ""} + i = 0 + while i < len(opts): + if opts[i] in vals and i + 1 < len(opts): + vals[opts[i]] = opts[i + 1] + i += 2 + else: + i += 1 + print(json.dumps(record(run_dir, key, vals["--file"], vals["--url"], vals["--title"]))) + return 0 + sys.stderr.write(f"unknown command: {cmd}\n") + return 2 + except (ValueError, json.JSONDecodeError) as e: + sys.stderr.write(f"error: {e}\n") + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/engine/render_doc.py b/engine/render_doc.py new file mode 100644 index 0000000..75e499a --- /dev/null +++ b/engine/render_doc.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""render_doc.py — deterministic Markdown -> self-contained HTML for stage artifacts. + +Stdlib-only (no third-party markdown lib), matching the engine's zero-dependency rule. +Turns a stage's committed .md (PRD, HLD, LLD, review pack…) into ONE self-contained, +theme-aware HTML fragment with no external assets. That fragment is: + + * publish-ready — it is \n" + '
\n' + f'

Stage artifact · {html.escape(title, quote=False)}

\n' + f"{body}\n" + "
\n" + ) + + +# --------------------------------------------------------------------------- cli + + +def main(argv): + args = [a for a in argv if not a.startswith("--")] + title = None + for a in argv: + if a.startswith("--title="): + title = a[len("--title="):] + if len(argv) >= 2 and "--title" in argv: + j = argv.index("--title") + if j + 1 < len(argv): + title = argv[j + 1] + args = [a for a in args if a != title] + if not args: + sys.stderr.write("usage: render_doc.py INPUT.md [OUTPUT.html] [--title T]\n") + return 2 + src = args[0] + dst = args[1] if len(args) > 1 else os.path.splitext(src)[0] + ".html" + with open(src, "r", encoding="utf-8") as fh: + md = fh.read() + out = render(md, title) + with open(dst, "w", encoding="utf-8") as fh: + fh.write(out) + print(dst) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/engine/tests/test_artifact_record.py b/engine/tests/test_artifact_record.py new file mode 100644 index 0000000..a9b6f61 --- /dev/null +++ b/engine/tests/test_artifact_record.py @@ -0,0 +1,76 @@ +"""artifact_record.py — the per-run manifest of published stage artifacts. + +Proves the mapping that lets a *revision* update the same shareable link: get returns +empty before anything is recorded; record upserts; a later path-only record (no url) +preserves a url an earlier run established; and the manifest write is atomic + stable. +""" +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import artifact_record # noqa: E402 + + +def run(argv): + buf = io.StringIO() + with redirect_stdout(buf): + rc = artifact_record.main(argv) + out = buf.getvalue().strip() + return rc, (json.loads(out) if out else None) + + +class ArtifactRecordTest(unittest.TestCase): + def setUp(self): + self.d = tempfile.mkdtemp() + + def test_get_empty_before_record(self): + rc, out = run(["get", self.d, "hld"]) + self.assertEqual(rc, 0) + self.assertEqual(out, {"key": "hld", "file": "", "url": "", "title": ""}) + + def test_record_then_get_roundtrip(self): + rc, _ = run(["record", self.d, "hld", "--file", "hld.html", + "--url", "https://claude.ai/code/artifact/abc", "--title", "HLD"]) + self.assertEqual(rc, 0) + _, out = run(["get", self.d, "hld"]) + self.assertEqual(out["url"], "https://claude.ai/code/artifact/abc") + self.assertEqual(out["file"], "hld.html") + self.assertEqual(out["title"], "HLD") + + def test_revision_updates_same_key(self): + run(["record", self.d, "hld", "--file", "hld.html", "--url", "u1"]) + run(["record", self.d, "hld", "--file", "hld.html", "--url", "u2"]) + _, out = run(["get", self.d, "hld"]) + self.assertEqual(out["url"], "u2") + # still one entry, not two + _, manifest = run(["list", self.d]) + self.assertEqual(list(manifest["artifacts"].keys()), ["hld"]) + + def test_path_only_record_preserves_existing_url(self): + # a Claude Code run sets the link; a later path-only harness must not wipe it + run(["record", self.d, "hld", "--file", "hld.html", "--url", "keepme"]) + run(["record", self.d, "hld", "--file", "hld.html"]) # no --url + _, out = run(["get", self.d, "hld"]) + self.assertEqual(out["url"], "keepme") + + def test_manifest_is_valid_json_on_disk(self): + run(["record", self.d, "prd", "--file", "prd.html", "--url", "u"]) + with open(os.path.join(self.d, "artifacts.json"), encoding="utf-8") as fh: + data = json.load(fh) + self.assertEqual(data["version"], 1) + self.assertIn("prd", data["artifacts"]) + + def test_malformed_manifest_fails_closed(self): + with open(os.path.join(self.d, "artifacts.json"), "w", encoding="utf-8") as fh: + fh.write("not json") + rc, _ = run(["get", self.d, "hld"]) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/tests/test_render_doc.py b/engine/tests/test_render_doc.py new file mode 100644 index 0000000..4216038 --- /dev/null +++ b/engine/tests/test_render_doc.py @@ -0,0 +1,108 @@ +"""render_doc.py — deterministic Markdown -> self-contained HTML for stage artifacts. + +Proves the render is: complete over the SDLC Markdown subset, self-contained (no external +assets), publish-ready (no // wrapper), and byte-deterministic so it is +golden-set testable like the rest of the engine. +""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import render_doc # noqa: E402 + + +SAMPLE = """# HLD — Auth feature + +Some **bold** and *italic* and `inline code` and a [link](https://example.com). + +## Decision summary + +- opaque token +- bcrypt hashing + +1. backend +2. react +3. flutter + +| Decision | Reason | +| --- | --- | +| Bearer token | one mechanism | +| SQLite | zero infra | + +> A quote about sessions. + +``` +GET /me +Authorization: Bearer +``` + +--- + +Done. +""" + + +class RenderDocTest(unittest.TestCase): + def setUp(self): + self.html = render_doc.render(SAMPLE) + + def test_covers_the_markdown_subset(self): + h = self.html + self.assertIn("

HLD — Auth feature

", h) + self.assertIn("

Decision summary

", h) + self.assertIn("bold", h) + self.assertIn("italic", h) + self.assertIn("inline code", h) + self.assertIn('link', h) + self.assertIn("
    ", h) + self.assertIn("
      ", h) + self.assertIn("", h) + self.assertIn("", h) + self.assertIn("", h) + self.assertIn("
      ", h) + self.assertIn("
      ", h)
      +        self.assertIn("
      ", h) + + def test_code_block_is_escaped_not_interpreted(self): + # angle brackets inside a fence must be escaped, never emitted as tags + self.assertIn("Bearer <token>", self.html) + + def test_self_contained_and_publish_ready(self): + h = self.html + # no document wrapper — the artifact publisher supplies it, and browsers + # still render a bare
      DecisionBearer token