@sushanglewis/lincoln 1.5.5 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/main.js +425 -51
  2. package/dist/main.js.map +1 -1
  3. package/dist/postinstall.d.ts +9 -2
  4. package/dist/postinstall.js +812 -6
  5. package/dist/postinstall.js.map +1 -1
  6. package/framework/.claude/agents/architect.md +15 -15
  7. package/framework/.claude/agents/default.md +118 -104
  8. package/framework/.claude/agents/designer.md +16 -51
  9. package/framework/.claude/agents/engineer.md +16 -25
  10. package/framework/.claude/agents/frontend-engineer.md +15 -16
  11. package/framework/.claude/agents/knowledge-curator.md +18 -15
  12. package/framework/.claude/agents/pm.md +16 -75
  13. package/framework/.claude/agents/qa.md +17 -13
  14. package/framework/.claude/agents/release-coordinator.md +18 -12
  15. package/framework/.claude/agents/researcher.md +16 -26
  16. package/framework/.claude/harnesses/command-map.yaml +13 -6
  17. package/framework/.claude/hooks/on-session-start.sh +0 -1
  18. package/framework/.claude/schemas/agent-definition.schema.json +6 -1
  19. package/framework/.claude/schemas/stage-definition.schema.json +49 -4
  20. package/framework/.claude/skills/build-product-prototype/prompts/main.md +89 -26
  21. package/framework/.claude/skills/clarify-requirements/prompts/main.md +80 -14
  22. package/framework/.claude/skills/draft-product-design/prompts/main.md +24 -12
  23. package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +2 -0
  24. package/framework/.claude/skills/lc-explore-opensource/SKILL.md +2 -0
  25. package/framework/.claude/skills/lc-init-branch/SKILL.md +2 -2
  26. package/framework/.claude/skills/lc-market-research/SKILL.md +1 -1
  27. package/framework/.claude/skills/lc-one-id/SKILL.md +81 -0
  28. package/framework/.claude/skills/lc-one-id/prompts/main.md +35 -0
  29. package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +1 -1
  30. package/framework/.claude/skills/lc-workflow-router/SKILL.md +2 -0
  31. package/framework/.claude/stages/build-codebase-knowledge.yaml +10 -10
  32. package/framework/.claude/stages/clarify.yaml +21 -15
  33. package/framework/.claude/stages/explore-opensource.yaml +13 -10
  34. package/framework/.claude/stages/implement.yaml +11 -11
  35. package/framework/.claude/stages/ingest.yaml +13 -10
  36. package/framework/.claude/stages/lc-analyze-frameworks.yaml +13 -10
  37. package/framework/.claude/stages/lc-collect-intelligence.yaml +13 -10
  38. package/framework/.claude/stages/lc-competitive-analysis.yaml +13 -10
  39. package/framework/.claude/stages/lc-first-principles.yaml +13 -10
  40. package/framework/.claude/stages/lc-market-research.yaml +13 -10
  41. package/framework/.claude/stages/lc-product-research.yaml +13 -10
  42. package/framework/.claude/stages/lc-research-report.yaml +13 -10
  43. package/framework/.claude/stages/lc-research-scope.yaml +13 -10
  44. package/framework/.claude/stages/lc-stakeholder-research.yaml +13 -10
  45. package/framework/.claude/stages/lc-storytelling.yaml +13 -10
  46. package/framework/.claude/stages/product-design-docs.yaml +30 -18
  47. package/framework/.claude/stages/product-prototype.yaml +20 -17
  48. package/framework/.claude/stages/propose.yaml +16 -9
  49. package/framework/.claude/stages/split.yaml +13 -9
  50. package/framework/.claude/stages/sync-knowledge.yaml +20 -9
  51. package/framework/.claude/stages/tdd-development-plan.yaml +17 -9
  52. package/framework/.claude/stages/workflow-router.yaml +17 -13
  53. package/framework/.claude/workflows/bug-fix.yaml +1 -0
  54. package/framework/.claude/workflows/existing-project-iteration.yaml +1 -0
  55. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  56. package/framework/.claude/workflows/oss-first-design.yaml +1 -0
  57. package/framework/.claude-plugin/marketplace.json +2 -2
  58. package/framework/.claude-plugin/plugin.json +2 -1
  59. package/framework/.version-bump.json +1 -1
  60. package/framework/README.en.md +15 -2
  61. package/framework/README.md +16 -3
  62. package/framework/RELEASE.md +48 -0
  63. package/framework/manifest.json +94 -74
  64. package/framework/scripts/check-prompt-drift.py +132 -1
  65. package/framework/scripts/extract_release_notes.py +79 -0
  66. package/framework/scripts/init-lincoln-branch.sh +6 -0
  67. package/framework/scripts/lincoln_command_map.py +1 -1
  68. package/framework/scripts/lincoln_id.py +333 -0
  69. package/framework/scripts/lincoln_index.py +10 -0
  70. package/framework/scripts/lincoln_prd.py +16 -17
  71. package/framework/scripts/lincoln_render.py +657 -0
  72. package/framework/scripts/run-infrastructure-tests.py +2 -0
  73. package/framework/scripts/stage_loader.py +149 -2
  74. package/framework/scripts/static-check.sh +1 -1
  75. package/framework/scripts/update_readme_version.py +149 -0
  76. package/framework/scripts/validate_stage.py +80 -1
  77. package/framework/tools/lincoln/package-lock.json +2 -2
  78. package/framework/tools/lincoln/package.json +1 -1
  79. package/framework/tools/lincoln-installer/package-lock.json +2 -2
  80. package/framework/tools/lincoln-installer/package.json +1 -1
  81. package/package.json +2 -2
  82. package/framework/.claude/agents/_contract.md +0 -79
@@ -0,0 +1,657 @@
1
+ #!/usr/bin/env python3
2
+ """Generic template renderer for Lincoln issue-package HTML pages.
3
+
4
+ Reads stage YAML `artifact_templates` mappings, replaces runtime variables and
5
+ page-level placeholders, injects Markdown or structured data for doc pages, and
6
+ writes the rendered HTML atomically. Intended to replace ad-hoc
7
+ copy-and-replace in skill prompts with a single CLI that agent prompts can
8
+ reference.
9
+
10
+ Examples:
11
+ python scripts/lincoln_render.py \\
12
+ --stage clarify \\
13
+ --target issue-42/pages/docs/prd.html \\
14
+ --title "产品需求文档" \\
15
+ --nav-label "PRD" \\
16
+ --nav-group "Docs" \\
17
+ --version v1.0 \\
18
+ --uid prd \\
19
+ --markdown issue-42/pages/docs/prd.md
20
+
21
+ python scripts/lincoln_render.py \\
22
+ --stage product-design-docs \\
23
+ --target issue-42/pages/docs/feature-catalog.html \\
24
+ --title "功能目录" \\
25
+ --nav-label "功能目录" \\
26
+ --version v1.0 \\
27
+ --uid feature-catalog \\
28
+ --data issue-42/pages/docs/feature-catalog.yaml
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import fnmatch
35
+ import html
36
+ import json
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
43
+ if str(PROJECT_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(PROJECT_ROOT))
45
+
46
+ import yaml
47
+
48
+ from scripts.lincoln_paths import (
49
+ PROJECT_ROOT as _PROJECT_ROOT,
50
+ atomic_write_text,
51
+ get_process_slug,
52
+ load_yaml,
53
+ resolve_state_path,
54
+ )
55
+
56
+ STAGES_DIR = PROJECT_ROOT / ".claude" / "stages"
57
+
58
+ # Placeholders that the renderer always supports.
59
+ PAGE_PLACEHOLDERS = {
60
+ "TITLE",
61
+ "NAV_LABEL",
62
+ "NAV_GROUP",
63
+ "VERSION",
64
+ "UID",
65
+ "STAGE",
66
+ "MARKDOWN_SOURCE",
67
+ }
68
+
69
+ # Placeholders rendered from structured page data.
70
+ DATA_PLACEHOLDERS = {
71
+ "PAGE_DATA",
72
+ "SECTIONS_HTML",
73
+ "STORIES_HTML",
74
+ "FEATURES_HTML",
75
+ "ENTITIES_HTML",
76
+ "FLOWS_HTML",
77
+ "PAGES_HTML",
78
+ "APIS_HTML",
79
+ "FIELDS_HTML",
80
+ }
81
+
82
+ # Variables commonly declared in stage YAML `variables` and sourced from
83
+ # workflow-stage.yaml `current_run.variables`.
84
+ RUNTIME_VARIABLES = {
85
+ "process_slug",
86
+ "session_id",
87
+ "design_id",
88
+ "change_name",
89
+ "feature_slug",
90
+ "pr_number",
91
+ "decision_id",
92
+ "requirement_id",
93
+ }
94
+
95
+ VERSION_COMMENT_RE = re.compile(r"<!--\s*version:\s*(v\d+\.\d+)\s*-->", re.IGNORECASE)
96
+ META_RE = re.compile(r'<meta\s+name="([^"]+)"\s+content="([^"]*)"\s*/?>')
97
+
98
+
99
+ def _load_stage(stage_id: str) -> dict[str, Any]:
100
+ path = STAGES_DIR / f"{stage_id}.yaml"
101
+ if not path.exists():
102
+ raise FileNotFoundError(f"Stage definition not found: {path}")
103
+ data = load_yaml(path)
104
+ if not isinstance(data, dict):
105
+ raise ValueError(f"Stage YAML root is not a mapping: {path}")
106
+ return data
107
+
108
+
109
+ def _load_runtime_variables(state_file: str | Path | None = None) -> dict[str, str]:
110
+ """Load runtime variables from the canonical workflow state file."""
111
+ if state_file is not None:
112
+ state_path = Path(state_file)
113
+ else:
114
+ state_path = resolve_state_path(None, PROJECT_ROOT)
115
+ if not state_path or not state_path.exists():
116
+ return {}
117
+ try:
118
+ state = load_yaml(state_path)
119
+ except Exception:
120
+ return {}
121
+ variables = state.get("current_run", {}).get("variables", {})
122
+ return {k: str(v) for k, v in variables.items() if isinstance(v, (str, int, float))}
123
+
124
+
125
+ def _load_page_data(data_path: str | Path | None) -> dict[str, Any]:
126
+ """Load optional structured page data from YAML or JSON."""
127
+ if not data_path:
128
+ return {}
129
+ path = Path(data_path)
130
+ if not path.is_absolute():
131
+ path = PROJECT_ROOT / path
132
+ if not path.exists():
133
+ raise FileNotFoundError(f"Data file not found: {path}")
134
+ text = path.read_text(encoding="utf-8")
135
+ if path.suffix.lower() in {".yaml", ".yml"}:
136
+ data = yaml.safe_load(text)
137
+ else:
138
+ data = json.loads(text)
139
+ if data is None:
140
+ return {}
141
+ if not isinstance(data, dict):
142
+ raise ValueError(f"Page data root must be an object: {path}")
143
+ return data
144
+
145
+
146
+ def _resolve_template(templates: dict[str, str], target: str) -> tuple[Path, str]:
147
+ """Return (template_path, matched_pattern) for *target* from interpolated templates."""
148
+ if not templates:
149
+ raise ValueError("No 'artifact_templates' mapping provided")
150
+
151
+ # Exact match first.
152
+ for pattern, template in templates.items():
153
+ if pattern == target:
154
+ return PROJECT_ROOT / template, pattern
155
+
156
+ # Then glob match; prefer the longest/most specific pattern.
157
+ matches: list[tuple[str, str]] = []
158
+ for pattern, template in templates.items():
159
+ if fnmatch.fnmatch(target, pattern):
160
+ matches.append((pattern, template))
161
+ if matches:
162
+ # Longer pattern string is usually more specific.
163
+ matches.sort(key=lambda x: len(x[0]), reverse=True)
164
+ return PROJECT_ROOT / matches[0][1], matches[0][0]
165
+
166
+ raise ValueError(f"No artifact_templates pattern matches target '{target}'")
167
+
168
+
169
+ def _replace_variables(text: str, variables: dict[str, str]) -> str:
170
+ """Replace {VAR} placeholders where VAR is present in variables."""
171
+
172
+ def repl(match: re.Match) -> str:
173
+ key = match.group(1)
174
+ if key in variables:
175
+ return variables[key]
176
+ # Preserve unknown placeholders so callers can see what is missing.
177
+ return match.group(0)
178
+
179
+ return re.sub(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, text)
180
+
181
+
182
+ def _escape_script_content(text: str) -> str:
183
+ """Escape content that would break an embedded <script> block."""
184
+ return text.replace("</script>", "<\\/script>")
185
+
186
+
187
+ def _escape_markdown_for_script(markdown: str) -> str:
188
+ """Escape content that would break the embedded <script type="text/markdown"> block."""
189
+ return markdown.replace("</script>", "<\\/script>")
190
+
191
+
192
+ def _html_attr(value: str) -> str:
193
+ return html.escape(value, quote=True)
194
+
195
+
196
+ def _render_sections_html(sections: Any) -> str:
197
+ """Render a list of sections as titled cards."""
198
+ if not isinstance(sections, list):
199
+ return ""
200
+ out = ['<div class="lincoln-sections">']
201
+ for section in sections:
202
+ if not isinstance(section, dict):
203
+ continue
204
+ title = section.get("title", "")
205
+ content = section.get("content", "")
206
+ out.append(f'<section class="lincoln-section">')
207
+ if title:
208
+ out.append(f'<h2 class="lincoln-section-title">{_html_attr(title)}</h2>')
209
+ out.append(f'<div class="lincoln-section-body">{_escape_script_content(str(content))}</div>')
210
+ out.append('</section>')
211
+ out.append('</div>')
212
+ return "\n".join(out)
213
+
214
+
215
+ def _render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
216
+ """Render a generic HTML table from rows and (key, label) columns."""
217
+ if not rows:
218
+ return ""
219
+ out = ['<table class="lincoln-data-table">', '<thead><tr>']
220
+ for _key, label in columns:
221
+ out.append(f'<th>{_html_attr(str(label))}</th>')
222
+ out.append('</tr></thead><tbody>')
223
+ for row in rows:
224
+ out.append('<tr>')
225
+ for key, _label in columns:
226
+ value = row.get(key, "") if isinstance(row, dict) else ""
227
+ out.append(f'<td>{_escape_script_content(str(value))}</td>')
228
+ out.append('</tr>')
229
+ out.append('</tbody></table>')
230
+ return "\n".join(out)
231
+
232
+
233
+ def _render_stories_html(stories: Any) -> str:
234
+ if not isinstance(stories, list):
235
+ return ""
236
+ columns = [
237
+ ("who", "角色"),
238
+ ("want", "想要"),
239
+ ("so", "以便"),
240
+ ("acceptance", "验收标准"),
241
+ ("source", "来源"),
242
+ ]
243
+ return _render_table(stories, columns)
244
+
245
+
246
+ def _render_features_html(features: Any) -> str:
247
+ if not isinstance(features, list):
248
+ return ""
249
+ columns = [
250
+ ("id", "ID"),
251
+ ("title", "功能"),
252
+ ("priority", "优先级"),
253
+ ("acceptance", "验收标准"),
254
+ ("source", "来源"),
255
+ ]
256
+ return _render_table(features, columns)
257
+
258
+
259
+ def _render_entities_html(entities: Any) -> str:
260
+ if not isinstance(entities, list):
261
+ return ""
262
+ out = ['<div class="lincoln-entities">']
263
+ for entity in entities:
264
+ if not isinstance(entity, dict):
265
+ continue
266
+ name = entity.get("name", "")
267
+ fields = entity.get("fields", [])
268
+ constraints = entity.get("constraints", "")
269
+ states = entity.get("states", "")
270
+ out.append(f'<section class="lincoln-entity"><h3>{_html_attr(str(name))}</h3>')
271
+ if constraints:
272
+ out.append(f'<p class="lincoln-meta"><strong>约束:</strong>{_escape_script_content(str(constraints))}</p>')
273
+ if states:
274
+ out.append(f'<p class="lincoln-meta"><strong>状态:</strong>{_escape_script_content(str(states))}</p>')
275
+ if isinstance(fields, list) and fields:
276
+ out.append(_render_table(fields, [
277
+ ("name", "字段名"),
278
+ ("type", "类型"),
279
+ ("required", "必填"),
280
+ ("description", "说明"),
281
+ ("source", "来源"),
282
+ ]))
283
+ out.append('</section>')
284
+ out.append('</div>')
285
+ return "\n".join(out)
286
+
287
+
288
+ def _render_flows_html(flows: Any) -> str:
289
+ if not isinstance(flows, list):
290
+ return ""
291
+ out = ['<div class="lincoln-flows">']
292
+ for flow in flows:
293
+ if not isinstance(flow, dict):
294
+ continue
295
+ name = flow.get("name", "")
296
+ flow_type = flow.get("type", "")
297
+ mermaid = flow.get("mermaid", "")
298
+ steps = flow.get("steps", [])
299
+ out.append(f'<section class="lincoln-flow"><h3>{_html_attr(str(name))}</h3>')
300
+ if flow_type:
301
+ out.append(f'<p class="lincoln-meta"><strong>类型:</strong>{_html_attr(str(flow_type))}</p>')
302
+ if mermaid:
303
+ out.append(f'<pre class="lincoln-mermaid">{_escape_script_content(str(mermaid))}</pre>')
304
+ if isinstance(steps, list) and steps:
305
+ out.append('<ol class="lincoln-flow-steps">')
306
+ for step in steps:
307
+ out.append(f'<li>{_escape_script_content(str(step))}</li>')
308
+ out.append('</ol>')
309
+ out.append('</section>')
310
+ out.append('</div>')
311
+ return "\n".join(out)
312
+
313
+
314
+ def _render_pages_html(pages: Any) -> str:
315
+ if not isinstance(pages, list):
316
+ return ""
317
+ columns = [
318
+ ("id", "ID"),
319
+ ("title", "标题"),
320
+ ("path", "路径"),
321
+ ("links", "关联"),
322
+ ("notes", "备注"),
323
+ ]
324
+ return _render_table(pages, columns)
325
+
326
+
327
+ def _render_apis_html(apis: Any) -> str:
328
+ if not isinstance(apis, list):
329
+ return ""
330
+ columns = [
331
+ ("name", "名称"),
332
+ ("method", "方法"),
333
+ ("endpoint", "端点"),
334
+ ("purpose", "用途"),
335
+ ("contract", "契约"),
336
+ ]
337
+ return _render_table(apis, columns)
338
+
339
+
340
+ def _render_fields_html(fields_spec: Any) -> str:
341
+ """Render field specifications, supporting either a list of groups or a flat list."""
342
+ if not isinstance(fields_spec, list):
343
+ return ""
344
+ # If first item has a "fields" key, treat as groups.
345
+ if fields_spec and isinstance(fields_spec[0], dict) and "fields" in fields_spec[0]:
346
+ out = ['<div class="lincoln-field-groups">']
347
+ for group in fields_spec:
348
+ if not isinstance(group, dict):
349
+ continue
350
+ title = group.get("title", "")
351
+ fields = group.get("fields", [])
352
+ out.append(f'<section class="lincoln-field-group"><h3>{_html_attr(str(title))}</h3>')
353
+ out.append(_render_table(fields, [
354
+ ("name", "字段名"),
355
+ ("type", "类型"),
356
+ ("required", "必填"),
357
+ ("validation", "校验"),
358
+ ("default", "默认值"),
359
+ ("copy", "文案"),
360
+ ("error", "错误提示"),
361
+ ("source", "来源"),
362
+ ]))
363
+ out.append('</section>')
364
+ out.append('</div>')
365
+ return "\n".join(out)
366
+
367
+ return _render_table(fields_spec, [
368
+ ("name", "字段名"),
369
+ ("type", "类型"),
370
+ ("required", "必填"),
371
+ ("validation", "校验"),
372
+ ("default", "默认值"),
373
+ ("copy", "文案"),
374
+ ("error", "错误提示"),
375
+ ("source", "来源"),
376
+ ])
377
+
378
+
379
+ def _data_variables(page_data: dict[str, Any]) -> dict[str, str]:
380
+ """Build placeholder variables from structured page data."""
381
+ variables: dict[str, str] = {}
382
+
383
+ # Raw JSON for client-side rendering.
384
+ json_text = json.dumps(page_data, ensure_ascii=False, sort_keys=True)
385
+ variables["PAGE_DATA"] = _escape_script_content(json_text)
386
+
387
+ # Pre-rendered HTML fragments.
388
+ variables["SECTIONS_HTML"] = _render_sections_html(page_data.get("sections"))
389
+ variables["STORIES_HTML"] = _render_stories_html(page_data.get("stories"))
390
+ variables["FEATURES_HTML"] = _render_features_html(page_data.get("features"))
391
+ variables["ENTITIES_HTML"] = _render_entities_html(page_data.get("entities"))
392
+ variables["FLOWS_HTML"] = _render_flows_html(page_data.get("flows"))
393
+ variables["PAGES_HTML"] = _render_pages_html(page_data.get("pages"))
394
+ variables["APIS_HTML"] = _render_apis_html(page_data.get("apis"))
395
+ variables["FIELDS_HTML"] = _render_fields_html(page_data.get("fields"))
396
+
397
+ return variables
398
+
399
+
400
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
401
+
402
+
403
+ def _html_comment_ranges(html: str) -> list[tuple[int, int]]:
404
+ """Return [(start, end), ...] for all HTML comment blocks."""
405
+ return [(m.start(), m.end()) for m in COMMENT_RE.finditer(html)]
406
+
407
+
408
+ def _inside_comment(pos: int, ranges: list[tuple[int, int]]) -> bool:
409
+ return any(start <= pos < end for start, end in ranges)
410
+
411
+
412
+ def _inject_meta(html: str, name: str, content: str) -> str:
413
+ """Add or update a <meta name="..." content="..."> tag in <head>."""
414
+ if not content:
415
+ return html
416
+ tag = f'<meta name="{name}" content="{content}">'
417
+ comment_ranges = _html_comment_ranges(html)
418
+
419
+ # Update an existing tag with the same name, if any (skip placeholders inside comments).
420
+ for match in META_RE.finditer(html):
421
+ if match.group(1) == name and not _inside_comment(match.start(), comment_ranges):
422
+ return html[: match.start()] + tag + html[match.end() :]
423
+
424
+ # Insert right after <head> (or before </head> if no <head> tag found).
425
+ head_open = re.search(r"<head[^>]*>", html, re.IGNORECASE)
426
+ if head_open:
427
+ insert_at = head_open.end()
428
+ return html[:insert_at] + "\n" + tag + html[insert_at:]
429
+ head_close = re.search(r"</head>", html, re.IGNORECASE)
430
+ if head_close:
431
+ insert_at = head_close.start()
432
+ return html[:insert_at] + tag + "\n" + html[insert_at:]
433
+ return html
434
+
435
+
436
+ def _inject_annotation_metas(html: str, page_data: dict[str, Any]) -> str:
437
+ """Inject portal annotation meta tags from page_data.annotations into <head>."""
438
+ annotations = page_data.get("annotations") if isinstance(page_data, dict) else None
439
+ if not isinstance(annotations, dict):
440
+ return html
441
+ for key, value in annotations.items():
442
+ content = str(value) if value is not None else ""
443
+ if not content:
444
+ continue
445
+ name = key if key.startswith("doc-") else f"doc-{key}"
446
+ html = _inject_meta(html, name, content)
447
+ return html
448
+
449
+
450
+ def _ensure_version_comment(html: str, version: str) -> str:
451
+ """Ensure the HTML contains a <!-- version: vX.Y --> marker."""
452
+ if not version:
453
+ return html
454
+ if VERSION_COMMENT_RE.search(html):
455
+ return html
456
+ # Insert before </body> or append.
457
+ body_close = re.search(r"</body>", html, re.IGNORECASE)
458
+ marker = f"\n<!-- version: {version} -->\n"
459
+ if body_close:
460
+ return html[: body_close.start()] + marker + html[body_close.start() :]
461
+ return html + marker
462
+
463
+
464
+ PAGE_UID_META_RE = re.compile(r'<meta\s+name="page-uid"', re.IGNORECASE)
465
+
466
+
467
+ def _resolve_meta_uid_kind(template: str, target: str) -> str:
468
+ """Return 'doc-uid' for doc pages, 'page-uid' for prototype pages."""
469
+ if "/prototype/" in target or PAGE_UID_META_RE.search(template):
470
+ return "page-uid"
471
+ return "doc-uid"
472
+
473
+
474
+ def render_page(
475
+ stage_id: str,
476
+ target: str,
477
+ title: str = "",
478
+ nav_label: str = "",
479
+ nav_group: str = "Docs",
480
+ version: str = "",
481
+ uid: str = "",
482
+ stage_mark: str = "",
483
+ markdown_source: str = "",
484
+ page_data: dict[str, Any] | None = None,
485
+ extra_variables: dict[str, str] | None = None,
486
+ state_file: str | Path | None = None,
487
+ ) -> str:
488
+ """Render a single HTML page and return the rendered text."""
489
+ stage_data = _load_stage(stage_id)
490
+
491
+ runtime = _load_runtime_variables(state_file)
492
+ variables: dict[str, str] = {k: "" for k in RUNTIME_VARIABLES}
493
+ variables.update(runtime)
494
+ if extra_variables:
495
+ variables.update(extra_variables)
496
+
497
+ raw_templates = stage_data.get("artifact_templates", {}) or {}
498
+ interpolated_templates = {
499
+ _replace_variables(str(pattern), variables): str(template_path)
500
+ for pattern, template_path in raw_templates.items()
501
+ }
502
+ target = _replace_variables(target, variables)
503
+ template_path, _ = _resolve_template(interpolated_templates, target)
504
+ template = template_path.read_text(encoding="utf-8")
505
+
506
+ # Page-level placeholders.
507
+ variables.update(
508
+ {
509
+ "TITLE": title,
510
+ "NAV_LABEL": nav_label or title,
511
+ "NAV_GROUP": nav_group or "Docs",
512
+ "VERSION": version,
513
+ "UID": uid,
514
+ "STAGE": stage_mark or stage_id,
515
+ "MARKDOWN_SOURCE": _escape_markdown_for_script(markdown_source),
516
+ }
517
+ )
518
+
519
+ # Structured data placeholders.
520
+ if page_data:
521
+ variables.update(_data_variables(page_data))
522
+ else:
523
+ for key in DATA_PLACEHOLDERS:
524
+ variables.setdefault(key, "")
525
+
526
+ html = _replace_variables(template, variables)
527
+
528
+ # Auto-fill meta tags that the portal index relies on.
529
+ html = _inject_meta(html, "doc-title", title or nav_label)
530
+ html = _inject_meta(html, "nav-label", nav_label or title)
531
+ html = _inject_meta(html, "nav-group", nav_group or "Docs")
532
+ html = _inject_meta(html, "doc-version", version)
533
+ uid_kind = _resolve_meta_uid_kind(template, target)
534
+ html = _inject_meta(html, uid_kind, uid)
535
+
536
+ # Inject portal annotation metas from structured page data so the portal
537
+ # index and right-panel annotations are present in the static HTML source.
538
+ html = _inject_annotation_metas(html, page_data or {})
539
+
540
+ html = _ensure_version_comment(html, version)
541
+
542
+ return html
543
+
544
+
545
+ def _build_argument_parser() -> argparse.ArgumentParser:
546
+ parser = argparse.ArgumentParser(
547
+ description="Render a Lincoln HTML page from a stage template."
548
+ )
549
+ parser.add_argument("--stage", required=True, help="Stage ID (e.g. clarify)")
550
+ parser.add_argument(
551
+ "--target", required=True, help="Repo-relative target artifact path"
552
+ )
553
+ parser.add_argument("--title", default="", help="Page title")
554
+ parser.add_argument("--nav-label", default="", help="Navigation label")
555
+ parser.add_argument("--nav-group", default="Docs", help="Navigation group")
556
+ parser.add_argument("--version", default="", help="Version marker (e.g. v1.0)")
557
+ parser.add_argument("--uid", default="", help="Stable page/doc UID")
558
+ parser.add_argument(
559
+ "--stage-mark", default="", help="Stage label to display (defaults to stage ID)"
560
+ )
561
+ parser.add_argument(
562
+ "--markdown",
563
+ default="",
564
+ help="Path to a Markdown file to inject as the doc page source",
565
+ )
566
+ parser.add_argument(
567
+ "--data",
568
+ default="",
569
+ help="Path to a YAML or JSON file with structured page data",
570
+ )
571
+ parser.add_argument(
572
+ "--set",
573
+ action="append",
574
+ default=[],
575
+ help="Extra variable in key=value form (can be repeated)",
576
+ )
577
+ parser.add_argument(
578
+ "--state-file",
579
+ default=None,
580
+ help="Path to workflow-stage.yaml (defaults to canonical discovery)",
581
+ )
582
+ parser.add_argument(
583
+ "--dry-run",
584
+ action="store_true",
585
+ help="Print rendered HTML to stdout instead of writing",
586
+ )
587
+ return parser
588
+
589
+
590
+ def _parse_extra(variables: list[str]) -> dict[str, str]:
591
+ out: dict[str, str] = {}
592
+ for item in variables:
593
+ if "=" not in item:
594
+ raise ValueError(f"--set value must be key=value: {item}")
595
+ key, value = item.split("=", 1)
596
+ out[key] = value
597
+ return out
598
+
599
+
600
+ def main(argv: list[str] | None = None) -> int:
601
+ parser = _build_argument_parser()
602
+ args = parser.parse_args(argv)
603
+
604
+ markdown_source = ""
605
+ if args.markdown:
606
+ md_path = PROJECT_ROOT / args.markdown
607
+ if not md_path.exists():
608
+ print(f"Markdown source not found: {md_path}", file=sys.stderr)
609
+ return 1
610
+ markdown_source = md_path.read_text(encoding="utf-8")
611
+
612
+ page_data: dict[str, Any] | None = None
613
+ if args.data:
614
+ data_path = PROJECT_ROOT / args.data
615
+ if not data_path.exists():
616
+ print(f"Data file not found: {data_path}", file=sys.stderr)
617
+ return 1
618
+ try:
619
+ page_data = _load_page_data(data_path)
620
+ except Exception as exc:
621
+ print(f"Failed to load data file: {exc}", file=sys.stderr)
622
+ return 1
623
+
624
+ extra = _parse_extra(args.set)
625
+
626
+ try:
627
+ html = render_page(
628
+ stage_id=args.stage,
629
+ target=args.target,
630
+ title=args.title,
631
+ nav_label=args.nav_label,
632
+ nav_group=args.nav_group,
633
+ version=args.version,
634
+ uid=args.uid,
635
+ stage_mark=args.stage_mark,
636
+ markdown_source=markdown_source,
637
+ page_data=page_data,
638
+ extra_variables=extra,
639
+ state_file=args.state_file,
640
+ )
641
+ except Exception as exc:
642
+ print(f"Render failed: {exc}", file=sys.stderr)
643
+ return 1
644
+
645
+ if args.dry_run:
646
+ print(html)
647
+ return 0
648
+
649
+ target_path = PROJECT_ROOT / args.target
650
+ target_path.parent.mkdir(parents=True, exist_ok=True)
651
+ atomic_write_text(target_path, html)
652
+ print(f"Rendered {target_path}")
653
+ return 0
654
+
655
+
656
+ if __name__ == "__main__":
657
+ sys.exit(main())
@@ -18,6 +18,8 @@ ROOT = Path(__file__).resolve().parents[1]
18
18
  INFRASTRUCTURE_TESTS = [
19
19
  "tests/test_yaml_schemas.py",
20
20
  "tests/test_bump_version.py",
21
+ "tests/test_update_readme_version.py",
22
+ "tests/test_extract_release_notes.py",
21
23
  "tests/test_harness_default_off.py",
22
24
  "tests/test_agent_contract.py",
23
25
  "tests/test_session_start_shape.py",