@michelj/context-guard 0.4.0

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.
@@ -0,0 +1,4661 @@
1
+ #!/usr/bin/env python3
2
+ """Utilities for Context Guard project context folders."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ import html
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ import io
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import webbrowser
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from urllib.parse import urlparse
23
+
24
+
25
+ def context_guard_skill_root() -> Path:
26
+ return Path(__file__).resolve().parents[1]
27
+
28
+
29
+ def is_inside(path: Path, parent: Path) -> bool:
30
+ try:
31
+ path.resolve().relative_to(parent.resolve())
32
+ return True
33
+ except ValueError:
34
+ return False
35
+
36
+
37
+ def is_context_guard_skill_path(path: Path) -> bool:
38
+ return is_inside(path, context_guard_skill_root())
39
+
40
+
41
+ def folder_root(cwd: Path) -> Path:
42
+ try:
43
+ out = subprocess.check_output(
44
+ ["git", "rev-parse", "--show-toplevel"],
45
+ cwd=str(cwd),
46
+ stderr=subprocess.DEVNULL,
47
+ text=True,
48
+ timeout=2,
49
+ ).strip()
50
+ if out:
51
+ return Path(out)
52
+ except Exception:
53
+ pass
54
+ return cwd
55
+
56
+
57
+ def guard_implicit_skill_root(root: Path, explicit_root: bool) -> int:
58
+ if explicit_root or not is_context_guard_skill_path(root):
59
+ return 0
60
+ print(
61
+ "[context-guard] refusing to use the Context Guard skill directory as a project context root. "
62
+ "Run again from the opened Codex workspace or pass an explicit project `--root`.",
63
+ file=sys.stderr,
64
+ )
65
+ return 2
66
+
67
+
68
+ def context_dir(root: Path) -> Path:
69
+ return root / ".codex" / "context"
70
+
71
+
72
+ def roadmap_output_dir(root: Path) -> Path:
73
+ return context_dir(root) / "roadmap"
74
+
75
+
76
+ def test_hub_dir(root: Path) -> Path:
77
+ return context_dir(root) / "test-hub"
78
+
79
+
80
+ def normalize_record_language(language: str) -> str:
81
+ value = " ".join((language or "").strip().split())
82
+ lowered = value.lower().replace("_", "-")
83
+ aliases = {
84
+ "zh": "zh",
85
+ "zh-cn": "zh",
86
+ "zh-hans": "zh",
87
+ "cn": "zh",
88
+ "chinese": "zh",
89
+ "中文": "zh",
90
+ "简体中文": "zh",
91
+ "en": "en",
92
+ "en-us": "en",
93
+ "english": "en",
94
+ "英文": "en",
95
+ }
96
+ return aliases.get(lowered, value or "unset")
97
+
98
+
99
+ def display_language_code(language: str) -> str:
100
+ normalized = normalize_record_language(language)
101
+ return normalized if normalized in {"zh", "en"} else "auto"
102
+
103
+
104
+ def default_preferences(today: str | None = None) -> dict[str, str]:
105
+ return {
106
+ "record_language": "unset",
107
+ "display_language": "auto",
108
+ "last_updated": today or datetime.now().strftime("%Y-%m-%d"),
109
+ "note": "Set with: context_guard.py set-language --language <language>",
110
+ }
111
+
112
+
113
+ def read_preferences(ctx: Path) -> dict[str, str]:
114
+ path = ctx / "preferences.json"
115
+ if not path.exists():
116
+ return {}
117
+ try:
118
+ data = json.loads(path.read_text(encoding="utf-8"))
119
+ except json.JSONDecodeError:
120
+ return {}
121
+ return data if isinstance(data, dict) else {}
122
+
123
+
124
+ def write_preferences(ctx: Path, preferences: dict[str, str]) -> None:
125
+ ctx.mkdir(parents=True, exist_ok=True)
126
+ (ctx / "preferences.json").write_text(
127
+ json.dumps(preferences, ensure_ascii=False, indent=2) + "\n",
128
+ encoding="utf-8",
129
+ )
130
+
131
+
132
+ def preferred_display_language(ctx: Path) -> str:
133
+ preferences = read_preferences(ctx)
134
+ configured = str(preferences.get("display_language") or preferences.get("record_language") or "auto")
135
+ return display_language_code(configured)
136
+
137
+
138
+ def write_if_missing(path: Path, content: str) -> bool:
139
+ if path.exists():
140
+ return False
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ path.write_text(content, encoding="utf-8")
143
+ return True
144
+
145
+
146
+ def init_context(root: Path) -> list[Path]:
147
+ today = datetime.now().strftime("%Y-%m-%d")
148
+ ctx = context_dir(root)
149
+ created: list[Path] = []
150
+ for directory in [
151
+ ctx,
152
+ ctx / "tasks",
153
+ ctx / "task-cases",
154
+ ctx / "bad-case-tests",
155
+ ctx / "test-hub",
156
+ ctx / "roadmap",
157
+ ctx / "exports",
158
+ ctx / "archive",
159
+ ]:
160
+ if not directory.exists():
161
+ directory.mkdir(parents=True, exist_ok=True)
162
+ created.append(directory)
163
+
164
+ files = {
165
+ ctx / "index.md": f"""# Context Index
166
+
167
+ This is a dynamic queue of active and recently parked folder context. Keep it short.
168
+
169
+ ## Quick Scan
170
+
171
+ - Current: none
172
+ - Latest roadmap node: none
173
+ - Hot bad-case tags: none
174
+ - Resume candidate: none
175
+
176
+ ## Current
177
+
178
+ None yet.
179
+
180
+ ## Parked / Resume Candidates
181
+
182
+ None.
183
+
184
+ ## Archived
185
+
186
+ Keep only concise summaries here. Move detailed stale context to `.codex/context/archive/`.
187
+
188
+ Last initialized: {today}
189
+ """,
190
+ ctx / "roadmap.md": f"""# Context Roadmap
191
+
192
+ This is the mainline route through the task. Keep nodes concise. Do not record every tiny action.
193
+
194
+ ## Nodes
195
+
196
+ No nodes yet.
197
+
198
+ Last initialized: {today}
199
+ """,
200
+ ctx / "bad-cases.md": f"""# Bad Case Register
201
+
202
+ This register tracks bad cases found during development and the guards that prevent them from recurring.
203
+
204
+ ## Active Cases
205
+
206
+ None.
207
+
208
+ ## Resolved History
209
+
210
+ None.
211
+
212
+ Last initialized: {today}
213
+ """,
214
+ ctx / "preferences.json": json.dumps(default_preferences(today), ensure_ascii=False, indent=2) + "\n",
215
+ }
216
+ for path, content in files.items():
217
+ if write_if_missing(path, content):
218
+ created.append(path)
219
+ return created
220
+
221
+
222
+ def set_record_language(root: Path, language: str) -> Path:
223
+ init_context(root)
224
+ ctx = context_dir(root)
225
+ normalized = normalize_record_language(language)
226
+ preferences = default_preferences()
227
+ preferences.update(read_preferences(ctx))
228
+ preferences["record_language"] = normalized
229
+ preferences["display_language"] = display_language_code(normalized)
230
+ preferences["last_updated"] = datetime.now().strftime("%Y-%m-%d")
231
+ write_preferences(ctx, preferences)
232
+ print(f"[context-guard] record language set: {normalized}")
233
+ return ctx / "preferences.json"
234
+
235
+
236
+ def export_roadmap(root: Path, output_format: str = "html") -> Path:
237
+ ctx = context_dir(root)
238
+ init_context(root)
239
+ source = ctx / "roadmap.md"
240
+ out_dir = roadmap_output_dir(root)
241
+ out_dir.mkdir(parents=True, exist_ok=True)
242
+ suffix = "html" if output_format == "html" else "md"
243
+ dest = out_dir / f"roadmap.{suffix}"
244
+ roadmap = source.read_text(encoding="utf-8")
245
+ index = (ctx / "index.md").read_text(encoding="utf-8")
246
+ bad_cases = (ctx / "bad-cases.md").read_text(encoding="utf-8")
247
+ if output_format == "html":
248
+ detail_dest = out_dir / "roadmap-details.html"
249
+ for old_html in out_dir.glob("*.html"):
250
+ if old_html not in {dest, detail_dest}:
251
+ old_html.unlink()
252
+ (out_dir / "roadmap.md").write_text(render_roadmap_markdown(ctx, index, roadmap, bad_cases), encoding="utf-8")
253
+ (out_dir / "roadmap.json").write_text(
254
+ json.dumps(build_agent_roadmap_index(ctx, index, roadmap, bad_cases), ensure_ascii=False, indent=2) + "\n",
255
+ encoding="utf-8",
256
+ )
257
+ dest.write_text(render_roadmap_html(ctx, index, roadmap, bad_cases), encoding="utf-8")
258
+ detail_dest.write_text(render_roadmap_details_html(ctx, index, roadmap, bad_cases), encoding="utf-8")
259
+ return dest
260
+ dest.write_text(render_roadmap_markdown(ctx, index, roadmap, bad_cases), encoding="utf-8")
261
+ return dest
262
+
263
+
264
+ def show_roadmap(root: Path, open_browser: bool = False) -> Path:
265
+ dest = export_roadmap(root, "html")
266
+ uri = dest.resolve().as_uri()
267
+ print(f"[context-guard] roadmap html: {dest}")
268
+ print(f"[context-guard] roadmap url: {uri}")
269
+ if open_browser:
270
+ webbrowser.open(uri)
271
+ return dest
272
+
273
+
274
+ def render_roadmap_markdown(ctx: Path, index: str, roadmap: str, bad_cases: str) -> str:
275
+ return "\n".join(
276
+ [
277
+ "# Agent-readable context roadmap",
278
+ "",
279
+ f"- Source folder: `{ctx}`",
280
+ f"- Exported: {datetime.now().isoformat(timespec='seconds')}",
281
+ "- Source of truth: `index.md`, `roadmap.md`, `bad-cases.md`, and task context files",
282
+ "- Human-facing view: `roadmap.html`",
283
+ "",
284
+ "## Quick Scan",
285
+ "",
286
+ extract_section(index, "## Quick Scan"),
287
+ "",
288
+ "## Roadmap",
289
+ "",
290
+ roadmap,
291
+ "",
292
+ "## Bad Case Tags And Links",
293
+ "",
294
+ extract_bad_case_scan(bad_cases),
295
+ "",
296
+ ]
297
+ )
298
+
299
+
300
+ def build_agent_roadmap_index(ctx: Path, index: str, roadmap: str, bad_cases: str) -> dict[str, object]:
301
+ nodes = parse_roadmap_nodes(roadmap)
302
+ cards = parse_bad_case_cards(bad_cases)
303
+ route_groups = group_nodes_by_branch(nodes)
304
+ case_by_id = {bad_case_id(card): card for card in cards if bad_case_id(card)}
305
+
306
+ indexed_nodes: list[dict[str, object]] = []
307
+ for source_number, node in enumerate(nodes, 1):
308
+ linked_ids = linked_bad_case_ids_for_node(node, cards)
309
+ indexed_nodes.append(
310
+ {
311
+ "id": node_id(node),
312
+ "source_number": source_number,
313
+ "title": human_title(node.get("title", f"Node {source_number}")),
314
+ "status": node.get("status", "unknown"),
315
+ "level": node_level(node),
316
+ "branch": branch_name(node),
317
+ "parent": normalized_parent_id(node.get("parent", "")),
318
+ "date": node.get("date", ""),
319
+ "task": strip_wrapping_backticks(node.get("task", "")),
320
+ "outcome": human_text(node.get("outcome", "")),
321
+ "linked_bad_cases": linked_ids,
322
+ "test_chain": human_text(node.get("test chain", "")),
323
+ }
324
+ )
325
+
326
+ indexed_cases = [
327
+ {
328
+ "id": cid,
329
+ "title": human_title(card.get("title", cid)),
330
+ "status": card.get("status", "unknown"),
331
+ "roadmap_nodes": node_ids_from_text(card.get("roadmap nodes", "")),
332
+ "tags": parse_tags(card.get("tags", "")),
333
+ "phenomenon": human_text(card.get("phenomenon", "")),
334
+ "trigger": human_text(card.get("trigger / reproduction", "")),
335
+ "guard": human_text(card.get("guard / verification", "")),
336
+ "reusable_guard_path": strip_wrapping_backticks(card.get("reusable guard path", "")),
337
+ }
338
+ for cid, card in case_by_id.items()
339
+ ]
340
+
341
+ return {
342
+ "schema": "context-guard-roadmap-v1",
343
+ "source_folder": str(ctx),
344
+ "source_files": {
345
+ "index": "index.md",
346
+ "roadmap": "roadmap.md",
347
+ "bad_cases": "bad-cases.md",
348
+ },
349
+ "quick_scan": extract_section(index, "## Quick Scan"),
350
+ "routes": [
351
+ {
352
+ "branch": branch,
353
+ "nodes": [node_id(node) for _, node in items],
354
+ }
355
+ for branch, items in route_groups
356
+ ],
357
+ "nodes": indexed_nodes,
358
+ "bad_cases": indexed_cases,
359
+ }
360
+
361
+
362
+ def language_script(title_key: str, default_lang: str = "auto") -> str:
363
+ default_lang = default_lang if default_lang in {"zh", "en"} else "auto"
364
+ return f"""<script>
365
+ const DEFAULT_LANG = "{default_lang}";
366
+ const I18N = {{
367
+ en: {{
368
+ roadmapTitle: "Context Roadmap",
369
+ roadmapDetails: "Roadmap Details",
370
+ humanView: "Human-facing view",
371
+ humanDetailView: "Human detail view",
372
+ updatedLabel: "Updated:",
373
+ roadmap: "Roadmap",
374
+ backToRoadmap: "Back to roadmap",
375
+ mainRoute: "Main Route",
376
+ badCases: "Bad Cases",
377
+ testChain: "Test Chain",
378
+ routeFocus: "Route Details",
379
+ emptyRoadmap: "No roadmap nodes recorded yet.",
380
+ noLinkedBadCases: "No linked bad cases.",
381
+ noBadCases: "No bad cases recorded.",
382
+ checkpointsInDetails: "{{count}} checkpoints in details",
383
+ levelMajor: "Major",
384
+ levelCheckpoint: "Checkpoint",
385
+ summary: "Summary:",
386
+ route: "Route:",
387
+ parentRoute: "Parent route:",
388
+ outcome: "Outcome:",
389
+ decision: "Decision:",
390
+ avoidGoingBack: "Avoid going back:",
391
+ next: "Next:",
392
+ phenomenon: "Phenomenon:",
393
+ trigger: "Trigger:",
394
+ rootCause: "Root cause:",
395
+ fix: "Fix:",
396
+ guardType: "Guard type:",
397
+ guard: "Guard:",
398
+ redCondition: "Red condition:",
399
+ greenCondition: "Green condition:",
400
+ expectedFailureReason: "Expected failure reason:"
401
+ }},
402
+ zh: {{
403
+ roadmapTitle: "项目路线图",
404
+ roadmapDetails: "路线图详情",
405
+ humanView: "人类视图",
406
+ humanDetailView: "人类详情视图",
407
+ updatedLabel: "更新:",
408
+ roadmap: "路线图",
409
+ backToRoadmap: "返回路线图",
410
+ mainRoute: "主要路线",
411
+ badCases: "问题案例",
412
+ testChain: "测试链路",
413
+ routeFocus: "路线详情",
414
+ emptyRoadmap: "还没有路线节点。",
415
+ noLinkedBadCases: "无关联 bad case。",
416
+ noBadCases: "还没有 bad case。",
417
+ checkpointsInDetails: "{{count}} 个检查点在详情页",
418
+ levelMajor: "主节点",
419
+ levelCheckpoint: "检查点",
420
+ summary: "概括:",
421
+ route: "路线:",
422
+ parentRoute: "父路线:",
423
+ outcome: "结果:",
424
+ decision: "决策:",
425
+ avoidGoingBack: "避免回头:",
426
+ next: "下一步:",
427
+ phenomenon: "现象:",
428
+ trigger: "触发:",
429
+ rootCause: "根因:",
430
+ fix: "修复:",
431
+ guardType: "防线类型:",
432
+ guard: "防线:",
433
+ redCondition: "红灯条件:",
434
+ greenCondition: "绿灯条件:",
435
+ expectedFailureReason: "预期失败原因:"
436
+ }}
437
+ }};
438
+
439
+ function resolveLang() {{
440
+ const query = new URLSearchParams(window.location.search).get("lang");
441
+ if (query === "zh" || query === "en") return query;
442
+ if (DEFAULT_LANG === "zh" || DEFAULT_LANG === "en") return DEFAULT_LANG;
443
+ const saved = localStorage.getItem("contextGuardLang");
444
+ if (saved === "zh" || saved === "en") return saved;
445
+ return (navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en";
446
+ }}
447
+
448
+ function applyLang(lang) {{
449
+ const dictionary = I18N[lang] || I18N.en;
450
+ document.documentElement.lang = lang;
451
+ document.title = dictionary["{title_key}"] || document.title;
452
+ localStorage.setItem("contextGuardLang", lang);
453
+ document.querySelectorAll("[data-i18n]").forEach((element) => {{
454
+ const key = element.dataset.i18n;
455
+ let value = dictionary[key] || I18N.en[key] || element.textContent;
456
+ if (element.dataset.count) value = value.replace("{{count}}", element.dataset.count);
457
+ element.textContent = value;
458
+ }});
459
+ document.querySelectorAll("[data-i18n-text]").forEach((element) => {{
460
+ const value = lang === "zh" ? element.dataset.zh : element.dataset.en;
461
+ if (value) element.textContent = value;
462
+ }});
463
+ }}
464
+
465
+ function connectorAnchor(element) {{
466
+ return element.querySelector(".lane-main .status-dot") || element.querySelector(".status-dot") || element;
467
+ }}
468
+
469
+ function dotConnectorPoint(element, stackRect, stack) {{
470
+ const anchor = connectorAnchor(element);
471
+ const rect = anchor.getBoundingClientRect();
472
+ return {{
473
+ x: rect.left + rect.width / 2 - stackRect.left + stack.scrollLeft,
474
+ y: rect.top + rect.height / 2 - stackRect.top + stack.scrollTop,
475
+ }};
476
+ }}
477
+
478
+ function connectorPoint(element, stackRect, stack, side = "center") {{
479
+ const anchor = connectorAnchor(element);
480
+ if (anchor !== element) return dotConnectorPoint(element, stackRect, stack);
481
+ const rect = element.getBoundingClientRect();
482
+ const y = rect.top + rect.height / 2 - stackRect.top + stack.scrollTop;
483
+ let x = rect.left + rect.width / 2;
484
+ if (side === "left") x = rect.left;
485
+ if (side === "right") x = rect.right;
486
+ if (side === "bottom") {{
487
+ x = rect.left + rect.width / 2;
488
+ return {{ x: x - stackRect.left + stack.scrollLeft, y: rect.bottom - stackRect.top + stack.scrollTop }};
489
+ }}
490
+ return {{ x: x - stackRect.left + stack.scrollLeft, y }};
491
+ }}
492
+
493
+ function cardConnectorPoint(element, stackRect, stack, side = "center") {{
494
+ const cardRect = element.getBoundingClientRect();
495
+ const anchorRect = connectorAnchor(element).getBoundingClientRect();
496
+ const y = anchorRect.top + anchorRect.height / 2 - stackRect.top + stack.scrollTop;
497
+ let x = cardRect.left + cardRect.width / 2;
498
+ if (side === "left") x = cardRect.left;
499
+ if (side === "right") x = cardRect.right;
500
+ return {{ x: x - stackRect.left + stack.scrollLeft, y }};
501
+ }}
502
+
503
+ function createConnectorPath(svg, d, className, attrs = {{}}) {{
504
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
505
+ path.setAttribute("d", d);
506
+ path.setAttribute("class", className);
507
+ Object.entries(attrs).forEach(([key, value]) => path.setAttribute(key, value));
508
+ svg.appendChild(path);
509
+ return path;
510
+ }}
511
+
512
+ function drawRouteConnectors(stack, svg, stackRect) {{
513
+ stack.querySelectorAll(".route-group").forEach((section) => {{
514
+ const cards = Array.from(section.querySelectorAll(".track-column.route-column[data-overview-node-id]"));
515
+ const routeLine = getComputedStyle(section).getPropertyValue("--route-line").trim() || "var(--line)";
516
+ cards.forEach((card, index) => {{
517
+ const next = cards[index + 1];
518
+ if (!next) return;
519
+ const start = cardConnectorPoint(card, stackRect, stack, "right");
520
+ const end = cardConnectorPoint(next, stackRect, stack, "left");
521
+ const handle = Math.max(28, (end.x - start.x) * 0.45);
522
+ const d = `M ${{start.x}} ${{start.y}} C ${{start.x + handle}} ${{start.y}} ${{end.x - handle}} ${{end.y}} ${{end.x}} ${{end.y}}`;
523
+ createConnectorPath(svg, d, "route-connector", {{
524
+ stroke: routeLine,
525
+ "data-route-link": `${{card.dataset.overviewNodeId}}:${{next.dataset.overviewNodeId}}`,
526
+ }});
527
+ }});
528
+ }});
529
+ }}
530
+
531
+ function branchCorridorX(source, target, stackRect, stack) {{
532
+ const parentRoute = source.closest(".route-group");
533
+ const cards = parentRoute ? Array.from(parentRoute.querySelectorAll(".track-column.route-column[data-overview-node-id]")) : [];
534
+ const index = cards.indexOf(source);
535
+ const sourceRect = source.getBoundingClientRect();
536
+ const previous = index > 0 ? cards[index - 1] : null;
537
+ if (previous) {{
538
+ const previousRect = previous.getBoundingClientRect();
539
+ return (previousRect.right + sourceRect.left) / 2 - stackRect.left + stack.scrollLeft;
540
+ }}
541
+ return sourceRect.left - stackRect.left + stack.scrollLeft - 24;
542
+ }}
543
+
544
+ function drawBranchConnectors() {{
545
+ const stack = document.querySelector("[data-route-map-overview]");
546
+ if (!stack) return;
547
+ const svg = stack.querySelector(":scope > .branch-connector-layer");
548
+ if (!svg) return;
549
+ const stackRect = stack.getBoundingClientRect();
550
+ const width = Math.max(stack.scrollWidth, stackRect.width);
551
+ const height = Math.max(stack.scrollHeight, stackRect.height);
552
+ svg.setAttribute("viewBox", `0 0 ${{width}} ${{height}}`);
553
+ svg.setAttribute("width", width);
554
+ svg.setAttribute("height", height);
555
+ svg.innerHTML = "";
556
+ drawRouteConnectors(stack, svg, stackRect);
557
+ stack.querySelectorAll(".route-group.route-branch[data-parent-anchor-id]").forEach((section) => {{
558
+ const parentId = section.dataset.parentAnchorId;
559
+ const source = parentId ? stack.querySelector(`[data-overview-node-id="${{CSS.escape(parentId)}}"]`) : null;
560
+ const target = section.querySelector(".track-column.route-column[data-overview-node-id]") || section.querySelector("[data-route-anchor]");
561
+ if (!source || !target) return;
562
+ const start = dotConnectorPoint(source, stackRect, stack);
563
+ const end = dotConnectorPoint(target, stackRect, stack);
564
+ const corridorX = branchCorridorX(source, target, stackRect, stack);
565
+ const handle = Math.max(18, Math.abs(start.x - corridorX) * 0.45);
566
+ const verticalHandle = Math.max(32, Math.abs(end.y - start.y) * 0.22);
567
+ const routeLine = getComputedStyle(section).getPropertyValue("--route-line").trim() || "var(--line)";
568
+ const d = [
569
+ `M ${{start.x}} ${{start.y}}`,
570
+ `C ${{start.x - handle}} ${{start.y}} ${{corridorX}} ${{start.y}} ${{corridorX}} ${{start.y + verticalHandle}}`,
571
+ `C ${{corridorX}} ${{end.y - verticalHandle}} ${{end.x - handle}} ${{end.y}} ${{end.x}} ${{end.y}}`,
572
+ ].join(" ");
573
+ createConnectorPath(svg, d, "branch-connector", {{
574
+ stroke: routeLine,
575
+ "data-parent-anchor-id": parentId,
576
+ "data-child-route": section.dataset.routeGroup || "",
577
+ "data-branch-corridor-x": String(Math.round(corridorX)),
578
+ }});
579
+ }});
580
+ }}
581
+
582
+ function setupInlineDetails() {{
583
+ const panel = document.querySelector("[data-inline-details]");
584
+ if (!panel) return;
585
+ const cards = Array.from(panel.querySelectorAll(".detail-card[id]"));
586
+ const closeLinks = Array.from(panel.querySelectorAll("[data-detail-close]"));
587
+ const hidePanel = () => {{
588
+ cards.forEach((card) => card.classList.remove("is-active"));
589
+ panel.classList.remove("is-open");
590
+ panel.hidden = true;
591
+ }};
592
+ const showFromHash = () => {{
593
+ const id = decodeURIComponent((window.location.hash || "").replace(/^#/, ""));
594
+ const target = id ? cards.find((card) => card.id === id) : null;
595
+ if (!target) {{
596
+ hidePanel();
597
+ return;
598
+ }}
599
+ cards.forEach((card) => card.classList.toggle("is-active", card === target));
600
+ panel.hidden = false;
601
+ panel.classList.add("is-open");
602
+ target.setAttribute("tabindex", "-1");
603
+ requestAnimationFrame(() => target.focus({{ preventScroll: false }}));
604
+ }};
605
+ closeLinks.forEach((link) => link.addEventListener("click", () => setTimeout(hidePanel, 0)));
606
+ window.addEventListener("hashchange", showFromHash);
607
+ showFromHash();
608
+ }}
609
+
610
+ document.addEventListener("DOMContentLoaded", () => {{
611
+ const initial = resolveLang();
612
+ applyLang(initial);
613
+ const routeButtons = Array.from(document.querySelectorAll("[data-route-filter]"));
614
+ const routeExists = (route) => routeButtons.some((button) => button.dataset.routeFilter === route);
615
+ const applyRoute = (route) => {{
616
+ if (!routeExists(route)) return;
617
+ localStorage.setItem("contextGuardRoute", route);
618
+ routeButtons.forEach((button) => {{
619
+ button.setAttribute("aria-pressed", button.dataset.routeFilter === route ? "true" : "false");
620
+ }});
621
+ }};
622
+ const routeQuery = new URLSearchParams(window.location.search).get("route");
623
+ const savedRoute = localStorage.getItem("contextGuardRoute");
624
+ const firstRoute = routeButtons[0] && routeButtons[0].dataset.routeFilter;
625
+ if (routeButtons.length) applyRoute(routeExists(routeQuery) ? routeQuery : (routeExists(savedRoute) ? savedRoute : firstRoute));
626
+ routeButtons.forEach((button) => {{
627
+ button.addEventListener("click", () => {{
628
+ const route = button.dataset.routeFilter;
629
+ const url = new URL(window.location.href);
630
+ url.searchParams.set("route", route);
631
+ window.history.replaceState(null, "", url);
632
+ applyRoute(route);
633
+ }});
634
+ }});
635
+ drawBranchConnectors();
636
+ window.addEventListener("resize", drawBranchConnectors);
637
+ const stack = document.querySelector("[data-route-map-overview]");
638
+ if (stack) stack.addEventListener("scroll", drawBranchConnectors, {{ passive: true }});
639
+ setupInlineDetails();
640
+ }});
641
+ </script>"""
642
+
643
+
644
+ def initial_html_language(preferred_lang: str) -> str:
645
+ return preferred_lang if preferred_lang in {"zh", "en"} else "en"
646
+
647
+
648
+ def initial_html_title(title_key: str, preferred_lang: str) -> str:
649
+ if preferred_lang == "zh":
650
+ return "路线图详情" if title_key == "roadmapDetails" else "项目路线图"
651
+ return "Context Roadmap Details" if title_key == "roadmapDetails" else "Context Roadmap Human View"
652
+
653
+
654
+ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) -> str:
655
+ nodes = parse_roadmap_nodes(roadmap)
656
+ bad_case_cards = parse_bad_case_cards(bad_cases)
657
+ case_anchor_map = build_case_anchor_map(bad_case_cards)
658
+ route_groups = group_nodes_by_branch(nodes)
659
+ node_lookup = {node_id(node): node for _, items in route_groups for _, node in items}
660
+ branch_mode = len(route_groups) > 1
661
+ route_offsets = build_route_offsets(route_groups, node_lookup) if branch_mode else {}
662
+ route_parent_anchors = build_route_parent_anchors(route_groups, node_lookup) if branch_mode else {}
663
+ route_depths = build_route_depths(route_groups, node_lookup) if branch_mode else {}
664
+ route_nav = render_route_filter(route_groups) if branch_mode else ""
665
+ route_items = "\n".join(
666
+ render_route_group(
667
+ branch,
668
+ items,
669
+ bad_case_cards,
670
+ case_anchor_map,
671
+ branch_mode,
672
+ node_lookup,
673
+ route_offsets.get(branch.lower(), 0),
674
+ route_depths.get(branch.lower(), 0),
675
+ route_parent_anchors.get(branch.lower(), ("", ""))[0],
676
+ route_parent_anchors.get(branch.lower(), ("", ""))[1],
677
+ )
678
+ for i, (branch, items) in enumerate(route_groups)
679
+ )
680
+ inline_details = render_inline_details(nodes, bad_case_cards, case_anchor_map)
681
+ if not route_items:
682
+ route_items = '<section class="empty" data-i18n="emptyRoadmap">No roadmap nodes recorded yet.</section>'
683
+ connector_layer = (
684
+ '<svg class="branch-connector-layer" aria-hidden="true" focusable="false"></svg>'
685
+ if branch_mode
686
+ else ""
687
+ )
688
+ preferred_lang = preferred_display_language(ctx)
689
+ html_lang = initial_html_language(preferred_lang)
690
+ html_title = initial_html_title("roadmapTitle", preferred_lang)
691
+ return f"""<!doctype html>
692
+ <html lang="{html_lang}">
693
+ <head>
694
+ <meta charset="utf-8">
695
+ <meta name="viewport" content="width=device-width, initial-scale=1">
696
+ <title>{html.escape(html_title)}</title>
697
+ <style>
698
+ :root {{
699
+ color-scheme: light;
700
+ --bg: #f4f7ef;
701
+ --panel: #fffff8;
702
+ --card: #fffef7;
703
+ --ink: #243125;
704
+ --muted: #64705d;
705
+ --line: #cddcc5;
706
+ --accent: #37745b;
707
+ --accent-soft: #e4f2e8;
708
+ --warn: #b0733f;
709
+ --warn-soft: #f8ead9;
710
+ --ok: #2f7d63;
711
+ --ok-soft: #dff1e7;
712
+ --danger: #b94c4c;
713
+ --quiet: #99a78f;
714
+ --shadow: 0 14px 34px rgba(51, 83, 57, 0.12);
715
+ --radius: 8px;
716
+ --font-body: "Avenir Next", "Gill Sans", "PingFang SC", "Hiragino Sans GB", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
717
+ --font-heading: "Iowan Old Style", "Charter", "Songti SC", "STSong", Georgia, serif;
718
+ --card-border-width: 1px;
719
+ --card-transform: none;
720
+ --board-texture: radial-gradient(circle at 20% 15%, rgba(93, 135, 83, 0.12), transparent 24%), radial-gradient(circle at 82% 4%, rgba(183, 143, 92, 0.12), transparent 20%);
721
+ --board-texture-size: auto;
722
+ }}
723
+ * {{ box-sizing: border-box; }}
724
+ body {{
725
+ margin: 0;
726
+ background: var(--bg);
727
+ color: var(--ink);
728
+ font: 14px/1.58 var(--font-body);
729
+ text-rendering: optimizeLegibility;
730
+ }}
731
+ header {{
732
+ padding: 22px 32px 14px;
733
+ border-bottom: 1px solid var(--line);
734
+ background: var(--panel);
735
+ }}
736
+ h1 {{ margin: 0 0 4px; font-family: var(--font-heading); font-size: 24px; letter-spacing: 0; }}
737
+ .header-row {{ display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }}
738
+ .shell {{
739
+ padding: 16px 32px 30px;
740
+ }}
741
+ h2 {{ margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; }}
742
+ .route-filter {{
743
+ display: flex;
744
+ align-items: center;
745
+ gap: 8px;
746
+ flex-wrap: wrap;
747
+ margin: 0 0 14px;
748
+ }}
749
+ .route-filter-label {{
750
+ color: var(--muted);
751
+ font-size: 12px;
752
+ font-weight: 720;
753
+ margin-right: 2px;
754
+ }}
755
+ .route-filter button {{
756
+ border: 1px solid var(--line);
757
+ border-radius: 999px;
758
+ background: color-mix(in srgb, var(--panel) 82%, var(--accent-soft));
759
+ color: var(--muted);
760
+ cursor: pointer;
761
+ font: inherit;
762
+ font-size: 12px;
763
+ font-weight: 760;
764
+ padding: 5px 11px;
765
+ }}
766
+ .route-filter button[aria-pressed="true"] {{
767
+ background: var(--accent);
768
+ border-color: var(--accent);
769
+ color: #fff;
770
+ box-shadow: 0 0 0 4px var(--accent-soft);
771
+ }}
772
+ .track-board {{
773
+ background: var(--panel);
774
+ border: 1px solid var(--line);
775
+ border-radius: var(--radius);
776
+ box-shadow: var(--shadow);
777
+ padding: 16px;
778
+ background-image: var(--board-texture);
779
+ background-size: var(--board-texture-size);
780
+ }}
781
+ .route-stack {{
782
+ display: grid;
783
+ gap: 14px;
784
+ }}
785
+ .route-stack.branch-map {{
786
+ overflow-x: auto;
787
+ padding-bottom: 4px;
788
+ position: relative;
789
+ isolation: isolate;
790
+ scrollbar-width: none;
791
+ -ms-overflow-style: none;
792
+ }}
793
+ .route-stack.branch-map::-webkit-scrollbar, .route-strip::-webkit-scrollbar {{
794
+ width: 0;
795
+ height: 0;
796
+ display: none;
797
+ }}
798
+ .branch-connector-layer {{
799
+ position: absolute;
800
+ inset: 0;
801
+ pointer-events: none;
802
+ overflow: visible;
803
+ z-index: 0;
804
+ }}
805
+ .branch-connector, .route-connector {{
806
+ fill: none;
807
+ stroke: var(--line);
808
+ stroke-width: 1.7;
809
+ stroke-linecap: round;
810
+ stroke-linejoin: round;
811
+ opacity: 0.58;
812
+ }}
813
+ .route-connector {{
814
+ stroke-width: 1.25;
815
+ opacity: 0.36;
816
+ }}
817
+ .route-group {{
818
+ min-width: 0;
819
+ }}
820
+ .route-stack.branch-map .route-group {{
821
+ min-width: max-content;
822
+ position: relative;
823
+ z-index: 1;
824
+ }}
825
+ .route-group.route-branch {{
826
+ position: relative;
827
+ padding-right: var(--branch-drift, 0px);
828
+ }}
829
+ .route-branch .route-head-grid, .route-branch .route-strip {{
830
+ transform: translateX(var(--branch-drift, 0px));
831
+ }}
832
+ .route-head {{
833
+ display: flex;
834
+ align-items: center;
835
+ gap: 9px;
836
+ margin-bottom: 7px;
837
+ flex-wrap: wrap;
838
+ }}
839
+ .route-head-grid {{
840
+ min-height: auto;
841
+ margin-bottom: 9px;
842
+ align-items: start;
843
+ }}
844
+ .route-head-cell {{
845
+ min-width: 0;
846
+ position: relative;
847
+ z-index: 1;
848
+ }}
849
+ .route-head-cell .route-head {{
850
+ margin-bottom: 6px;
851
+ }}
852
+ .route-head-cell .checkpoint-strip {{
853
+ margin: 0;
854
+ }}
855
+ .route-mark {{
856
+ width: 10px;
857
+ height: 10px;
858
+ border-radius: 999px;
859
+ background: var(--route-accent, var(--accent));
860
+ box-shadow: 0 0 0 4px var(--route-soft, var(--accent-soft));
861
+ }}
862
+ .route-title {{
863
+ font-size: 13px;
864
+ font-family: var(--font-heading);
865
+ font-weight: 760;
866
+ }}
867
+ .route-pill {{
868
+ border-radius: 999px;
869
+ background: color-mix(in srgb, var(--route-soft, var(--accent-soft)) 62%, #fff);
870
+ color: var(--muted);
871
+ font-size: 11px;
872
+ font-weight: 680;
873
+ padding: 1px 7px;
874
+ }}
875
+ .route-parent {{
876
+ display: inline-flex;
877
+ align-items: center;
878
+ border-radius: 999px;
879
+ background: color-mix(in srgb, var(--warn-soft) 68%, #fff);
880
+ color: var(--muted);
881
+ font-size: 11px;
882
+ font-weight: 680;
883
+ padding: 2px 8px;
884
+ max-width: min(520px, 100%);
885
+ }}
886
+ .checkpoint-strip {{
887
+ display: flex;
888
+ align-items: center;
889
+ gap: 5px;
890
+ color: var(--muted);
891
+ font-size: 11px;
892
+ margin: -4px 0 9px;
893
+ }}
894
+ .checkpoint-dot {{
895
+ width: 6px;
896
+ height: 6px;
897
+ border-radius: 999px;
898
+ background: var(--quiet);
899
+ }}
900
+ .route-strip {{
901
+ overflow: auto;
902
+ padding-bottom: 2px;
903
+ scrollbar-width: none;
904
+ -ms-overflow-style: none;
905
+ }}
906
+ .route-stack.branch-map .route-strip {{
907
+ overflow: visible;
908
+ }}
909
+ .track-grid {{
910
+ display: grid;
911
+ grid-template-columns: 56px;
912
+ grid-auto-flow: column;
913
+ grid-auto-columns: minmax(220px, 280px);
914
+ gap: 14px;
915
+ min-height: auto;
916
+ align-items: stretch;
917
+ }}
918
+ .route-stack:not(.branch-map) .track-grid {{
919
+ grid-auto-columns: minmax(240px, 300px);
920
+ align-items: start;
921
+ }}
922
+ .track-grid.route-only {{
923
+ grid-template-columns: none;
924
+ grid-auto-columns: minmax(180px, 230px);
925
+ min-height: 104px;
926
+ }}
927
+ .track-grid.single-mainline {{
928
+ grid-auto-columns: minmax(240px, 300px);
929
+ min-height: 0;
930
+ }}
931
+ .route-head-grid.track-grid.route-only {{
932
+ min-height: 0;
933
+ }}
934
+ .route-spacer {{
935
+ min-height: 1px;
936
+ pointer-events: none;
937
+ }}
938
+ .track-column, .track-label-column {{
939
+ display: grid;
940
+ grid-template-rows: auto auto auto;
941
+ gap: 12px;
942
+ }}
943
+ .track-column.route-column {{
944
+ grid-template-rows: auto minmax(18px, auto);
945
+ position: relative;
946
+ }}
947
+ .track-column.route-column.no-test-line {{
948
+ grid-template-rows: auto;
949
+ }}
950
+ .track-label-column {{
951
+ position: sticky;
952
+ left: 0;
953
+ z-index: 2;
954
+ background: color-mix(in srgb, var(--panel) 88%, transparent);
955
+ backdrop-filter: blur(6px);
956
+ }}
957
+ .track-label-cell {{
958
+ display: flex;
959
+ align-items: center;
960
+ justify-content: center;
961
+ padding: 12px 0;
962
+ min-width: 0;
963
+ }}
964
+ .lane {{
965
+ border: var(--card-border-width) solid var(--line);
966
+ border-radius: var(--radius);
967
+ padding: 12px;
968
+ background: var(--card);
969
+ min-width: 0;
970
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75), var(--shadow);
971
+ transform: var(--card-transform);
972
+ }}
973
+ .branch-map .lane {{
974
+ padding: 10px 12px;
975
+ min-height: 92px;
976
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.72), 0 8px 20px rgba(51, 83, 57, 0.08);
977
+ }}
978
+ .branch-map .route-test-line {{
979
+ min-height: 18px;
980
+ border-top: 2px solid color-mix(in srgb, var(--ok) 56%, transparent);
981
+ padding-top: 7px;
982
+ margin: 0 10px;
983
+ }}
984
+ .branch-map .route-test-empty {{
985
+ opacity: 0.3;
986
+ }}
987
+ .route-test-note {{
988
+ display: grid;
989
+ gap: 2px;
990
+ border-left: 3px solid var(--ok);
991
+ border-radius: 9px;
992
+ background: color-mix(in srgb, var(--ok-soft) 68%, var(--card));
993
+ color: var(--accent);
994
+ padding: 6px 8px;
995
+ text-decoration: none;
996
+ box-shadow: 0 6px 16px rgba(51, 83, 57, 0.06);
997
+ }}
998
+ .route-test-note span {{
999
+ color: var(--accent);
1000
+ font-size: 12px;
1001
+ font-weight: 760;
1002
+ overflow: hidden;
1003
+ text-overflow: ellipsis;
1004
+ white-space: nowrap;
1005
+ }}
1006
+ .route-test-note small {{
1007
+ color: var(--muted);
1008
+ font-size: 11px;
1009
+ line-height: 1.35;
1010
+ display: -webkit-box;
1011
+ -webkit-line-clamp: 2;
1012
+ -webkit-box-orient: vertical;
1013
+ overflow: hidden;
1014
+ }}
1015
+ .route-test-more {{
1016
+ display: inline-flex;
1017
+ align-items: center;
1018
+ width: fit-content;
1019
+ border-radius: 999px;
1020
+ background: var(--route-soft, var(--accent-soft));
1021
+ color: var(--route-accent, var(--accent));
1022
+ font-size: 11px;
1023
+ font-weight: 760;
1024
+ margin-top: 5px;
1025
+ padding: 1px 7px;
1026
+ }}
1027
+ .lane-main {{ border-top: 4px solid var(--route-accent, var(--accent)); }}
1028
+ .lane-bad-cases {{ border-top: 4px solid var(--warn); }}
1029
+ .lane-test-chain {{ border-top: 4px solid var(--ok); }}
1030
+ .lane-empty {{
1031
+ display: none;
1032
+ border-color: transparent;
1033
+ background: transparent;
1034
+ box-shadow: none;
1035
+ pointer-events: none;
1036
+ }}
1037
+ .lane-label {{
1038
+ color: var(--muted);
1039
+ font-size: 12px;
1040
+ font-weight: 700;
1041
+ letter-spacing: 0;
1042
+ line-height: 1.15;
1043
+ margin: 0;
1044
+ text-transform: none;
1045
+ white-space: nowrap;
1046
+ writing-mode: vertical-rl;
1047
+ text-orientation: mixed;
1048
+ }}
1049
+ html[lang="zh"] .lane-label {{ text-orientation: upright; }}
1050
+ .lane-link {{
1051
+ color: inherit;
1052
+ text-decoration: none;
1053
+ display: block;
1054
+ }}
1055
+ .lane-link:hover h3, .detail-link:hover {{ color: var(--accent); }}
1056
+ .summary {{
1057
+ color: var(--muted);
1058
+ font-size: 13px;
1059
+ margin: 8px 0 0;
1060
+ display: -webkit-box;
1061
+ -webkit-line-clamp: 3;
1062
+ -webkit-box-orient: vertical;
1063
+ overflow: hidden;
1064
+ }}
1065
+ .route-stack:not(.branch-map) .lane-main {{
1066
+ min-height: 0;
1067
+ }}
1068
+ .route-stack:not(.branch-map) .lane h3 {{
1069
+ font-size: 14px;
1070
+ line-height: 1.32;
1071
+ }}
1072
+ .branch-map .summary {{
1073
+ display: none;
1074
+ }}
1075
+ .node-heading {{
1076
+ display: flex;
1077
+ gap: 8px;
1078
+ align-items: flex-start;
1079
+ margin-bottom: 8px;
1080
+ }}
1081
+ .branch-map .node-heading {{
1082
+ margin-bottom: 6px;
1083
+ }}
1084
+ .node-number {{
1085
+ flex: 0 0 auto;
1086
+ width: 28px; height: 28px; border-radius: 50%;
1087
+ display: grid; place-items: center;
1088
+ background: var(--route-accent, var(--accent)); color: white; font-weight: 760;
1089
+ box-shadow: 0 0 0 4px var(--route-soft, var(--accent-soft));
1090
+ }}
1091
+ .lane h3 {{ margin: 0; font-family: var(--font-heading); font-size: 15px; line-height: 1.35; }}
1092
+ .branch-map .lane h3 {{ font-size: 14px; line-height: 1.28; }}
1093
+ .node-meta {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }}
1094
+ .branch-map .node-meta {{ margin-bottom: 0; }}
1095
+ .pill {{
1096
+ display: inline-flex;
1097
+ align-items: center;
1098
+ border-radius: 999px;
1099
+ padding: 2px 8px;
1100
+ background: var(--route-soft, var(--accent-soft));
1101
+ color: var(--route-accent, var(--accent));
1102
+ font-size: 12px;
1103
+ font-weight: 600;
1104
+ }}
1105
+ .status-dot, .freq-dot {{
1106
+ flex: 0 0 auto;
1107
+ width: 11px;
1108
+ height: 11px;
1109
+ border-radius: 999px;
1110
+ display: inline-block;
1111
+ position: relative;
1112
+ box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.16);
1113
+ }}
1114
+ .status-ok {{ background: var(--ok); }}
1115
+ .status-active {{ background: var(--accent); }}
1116
+ .status-warn {{ background: var(--warn); }}
1117
+ .status-bad {{ background: var(--danger); }}
1118
+ .status-muted {{ background: var(--quiet); }}
1119
+ .freq-dot {{ width: 9px; height: 9px; background: var(--warn); box-shadow: 0 0 0 3px var(--warn-soft); }}
1120
+ .badcase {{ border-bottom: 1px solid var(--line); padding-bottom: 10px; margin-bottom: 10px; }}
1121
+ .badcase:last-child {{ border-bottom: 0; padding-bottom: 0; margin-bottom: 0; }}
1122
+ .badcase-head {{ display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; }}
1123
+ .badcase-markers {{ display: inline-flex; align-items: center; gap: 8px; margin-top: 4px; }}
1124
+ .badcase h3 {{ margin: 0 0 8px; font-size: 14px; }}
1125
+ .test-note {{
1126
+ border-bottom: 1px solid var(--line);
1127
+ padding-bottom: 10px;
1128
+ margin-bottom: 10px;
1129
+ }}
1130
+ .test-note:last-child {{
1131
+ border-bottom: 0;
1132
+ padding-bottom: 0;
1133
+ margin-bottom: 0;
1134
+ }}
1135
+ .test-note p {{
1136
+ color: var(--muted);
1137
+ margin: 5px 0 0;
1138
+ font-size: 13px;
1139
+ }}
1140
+ .test-note code {{
1141
+ color: var(--route-accent, var(--accent));
1142
+ font-family: "SFMono-Regular", Consolas, monospace;
1143
+ font-size: 12px;
1144
+ white-space: normal;
1145
+ overflow-wrap: anywhere;
1146
+ }}
1147
+ .tags {{ display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; }}
1148
+ .tag {{
1149
+ border-radius: 999px;
1150
+ padding: 2px 7px;
1151
+ font-size: 11px;
1152
+ font-weight: 650;
1153
+ white-space: nowrap;
1154
+ }}
1155
+ .tag-emoji {{ margin-right: 3px; }}
1156
+ .tag-blue {{ background: #dbeafe; color: #1e40af; }}
1157
+ .tag-amber {{ background: #fef3c7; color: #92400e; }}
1158
+ .tag-green {{ background: #dcfce7; color: #166534; }}
1159
+ .tag-rose {{ background: #ffe4e6; color: #9f1239; }}
1160
+ .tag-slate {{ background: #eef2f7; color: #334155; }}
1161
+ .tag-more {{ background: #f1f5f9; color: #64748b; }}
1162
+ .muted {{ color: var(--muted); }}
1163
+ .detail-link {{ color: var(--accent); font-weight: 650; text-decoration: none; font-size: 13px; }}
1164
+ .inline-details {{
1165
+ margin-top: 18px;
1166
+ }}
1167
+ .inline-details[hidden] {{
1168
+ display: none !important;
1169
+ }}
1170
+ .inline-details.is-open {{
1171
+ display: block;
1172
+ }}
1173
+ .inline-details h2 {{
1174
+ position: absolute;
1175
+ width: 1px;
1176
+ height: 1px;
1177
+ margin: -1px;
1178
+ padding: 0;
1179
+ overflow: hidden;
1180
+ clip: rect(0, 0, 0, 0);
1181
+ white-space: nowrap;
1182
+ border: 0;
1183
+ }}
1184
+ .detail-card {{
1185
+ display: none;
1186
+ background: var(--panel);
1187
+ border: 1px solid var(--line);
1188
+ border-radius: var(--radius);
1189
+ padding: 14px;
1190
+ box-shadow: var(--shadow);
1191
+ scroll-margin-top: 18px;
1192
+ }}
1193
+ .detail-card.is-active {{
1194
+ display: block;
1195
+ outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
1196
+ border-color: var(--accent);
1197
+ }}
1198
+ .detail-card h3 {{ margin: 0 0 8px; font-family: var(--font-heading); font-size: 17px; }}
1199
+ .field {{ margin: 7px 0; }}
1200
+ .field b {{ color: var(--muted); }}
1201
+ .detail-list {{ margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }}
1202
+ .detail-list li {{ margin: 0; padding-left: 12px; border-left: 3px solid var(--line); }}
1203
+ .level-chip {{ display: inline-block; border-radius: 999px; padding: 1px 7px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 650; }}
1204
+ .visual-meta {{ display: flex; align-items: center; gap: 9px; min-height: 16px; margin: 4px 0 10px; }}
1205
+ .inline-top {{ display: inline-block; margin-top: 8px; color: var(--accent); font-weight: 650; text-decoration: none; }}
1206
+ .empty {{ color: var(--muted); padding: 18px; border: 1px dashed var(--line); border-radius: 8px; }}
1207
+ @media (max-width: 980px) {{
1208
+ .shell {{ padding: 16px; }}
1209
+ .quick {{ grid-template-columns: 1fr 1fr; }}
1210
+ .track-grid {{ grid-auto-columns: minmax(260px, 82vw); }}
1211
+ header {{ padding: 22px 16px 14px; }}
1212
+ }}
1213
+ @media (max-width: 560px) {{
1214
+ h1 {{ font-size: 22px; }}
1215
+ }}
1216
+ </style>
1217
+ </head>
1218
+ <body>
1219
+ <header>
1220
+ <div class="header-row">
1221
+ <div>
1222
+ <h1 data-i18n="roadmapTitle">Context Roadmap</h1>
1223
+ </div>
1224
+ </div>
1225
+ </header>
1226
+ <div class="shell">
1227
+ <main class="track-board" id="roadmap-overview">
1228
+ <h2 data-i18n="roadmap">Roadmap</h2>
1229
+ {route_nav}
1230
+ <div class="route-stack{' branch-map' if branch_mode else ''}"{' data-route-map-overview' if branch_mode else ''}>{connector_layer}{route_items}</div>
1231
+ </main>
1232
+ {inline_details}
1233
+ </div>
1234
+ {language_script("roadmapTitle", preferred_lang)}
1235
+ </body>
1236
+ </html>
1237
+ """
1238
+
1239
+
1240
+ def render_roadmap_details_html(ctx: Path, index: str, roadmap: str, bad_cases: str) -> str:
1241
+ nodes = parse_roadmap_nodes(roadmap)
1242
+ cards = parse_bad_case_cards(bad_cases)
1243
+ case_anchor_map = build_case_anchor_map(cards)
1244
+ exported = datetime.now().isoformat(timespec="seconds")
1245
+ detail_items = human_detail_node_items(nodes)
1246
+ node_sections = "\n".join(render_node_detail(node, i, cards, case_anchor_map) for i, node in detail_items)
1247
+ if not node_sections:
1248
+ node_sections = '<section class="detail-card" data-i18n="emptyRoadmap">No roadmap nodes recorded yet.</section>'
1249
+ preferred_lang = preferred_display_language(ctx)
1250
+ html_lang = initial_html_language(preferred_lang)
1251
+ html_title = initial_html_title("roadmapDetails", preferred_lang)
1252
+ return f"""<!doctype html>
1253
+ <html lang="{html_lang}">
1254
+ <head>
1255
+ <meta charset="utf-8">
1256
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1257
+ <title>{html.escape(html_title)}</title>
1258
+ <style>
1259
+ body {{ margin: 0; background: #f8faf6; color: #223126; font: 15px/1.68 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
1260
+ header {{ background: #fbfcf7; border-bottom: 1px solid #c9dcc8; padding: 22px 32px; }}
1261
+ main {{ max-width: 1040px; margin: 0 auto; padding: 22px 18px 44px; }}
1262
+ .header-row {{ display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }}
1263
+ h1 {{ margin: 0 0 4px; font-size: 24px; }}
1264
+ h2 {{ margin-top: 0; }}
1265
+ h3 {{ margin: 0 0 14px; font-size: 21px; }}
1266
+ h4 {{ margin: 0 0 8px; font-size: 14px; color: #2f7d60; letter-spacing: .02em; }}
1267
+ .meta, .muted {{ color: #687466; }}
1268
+ .detail-card {{ background: #fffefa; border: 1px solid #c9dcc8; border-left: 4px solid #2f7d60; border-radius: 8px; padding: 20px; margin: 16px 0; box-shadow: 0 12px 30px rgba(47, 80, 54, .08); }}
1269
+ .detail-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }}
1270
+ .detail-section {{ border-top: 1px solid #dbe8d7; padding-top: 12px; min-width: 0; }}
1271
+ .detail-section p {{ margin: 0; }}
1272
+ .detail-section.wide {{ grid-column: 1 / -1; }}
1273
+ .detail-list {{ margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }}
1274
+ .detail-list li {{ margin: 0; padding-left: 12px; border-left: 3px solid #dbe8d7; }}
1275
+ .node-case {{ border: 1px solid #dbe8d7; border-radius: 8px; padding: 10px 12px; margin: 8px 0; background: #fbfcf7; }}
1276
+ .node-case-title {{ margin: 0 0 4px; font-weight: 750; color: #2f7d60; }}
1277
+ .node-case p {{ margin: 0; }}
1278
+ .field {{ margin: 8px 0; }}
1279
+ .field b {{ color: #687466; }}
1280
+ .level-chip {{ display: inline-block; border-radius: 999px; padding: 1px 7px; background: #f1f5f9; color: #475569; font-size: 12px; font-weight: 650; }}
1281
+ .visual-meta {{ display: flex; align-items: center; gap: 9px; min-height: 16px; margin: 4px 0 12px; }}
1282
+ .status-dot, .freq-dot {{ flex: 0 0 auto; border-radius: 999px; display: inline-block; }}
1283
+ .status-dot {{ width: 11px; height: 11px; box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.16); }}
1284
+ .freq-dot {{ width: 9px; height: 9px; background: #b45309; box-shadow: 0 0 0 3px #fff5df; }}
1285
+ .status-ok {{ background: #047857; }}
1286
+ .status-active {{ background: #2563eb; }}
1287
+ .status-warn {{ background: #b45309; }}
1288
+ .status-bad {{ background: #dc2626; }}
1289
+ .status-muted {{ background: #94a3b8; }}
1290
+ .tags {{ display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }}
1291
+ .tag {{ display: inline-block; border-radius: 999px; padding: 2px 7px; font-size: 12px; font-weight: 650; }}
1292
+ .tag-emoji {{ margin-right: 3px; }}
1293
+ .tag-blue {{ background: #dbeafe; color: #1e40af; }}
1294
+ .tag-amber {{ background: #fef3c7; color: #92400e; }}
1295
+ .tag-green {{ background: #dcfce7; color: #166534; }}
1296
+ .tag-rose {{ background: #ffe4e6; color: #9f1239; }}
1297
+ .tag-slate {{ background: #eef2f7; color: #334155; }}
1298
+ .tag-more {{ background: #f1f5f9; color: #64748b; }}
1299
+ a {{ color: #2563eb; text-decoration: none; font-weight: 650; }}
1300
+ </style>
1301
+ </head>
1302
+ <body>
1303
+ <header>
1304
+ <div class="header-row">
1305
+ <div>
1306
+ <h1 data-i18n="roadmapDetails">Roadmap Details</h1>
1307
+ <div class="meta"><span data-i18n="humanDetailView">Human detail view</span> · <span data-i18n="updatedLabel">Updated:</span> {html.escape(exported)} · <a href="roadmap.html" data-i18n="backToRoadmap">Back to roadmap</a></div>
1308
+ </div>
1309
+ </div>
1310
+ </header>
1311
+ <main>
1312
+ {node_sections}
1313
+ </main>
1314
+ {language_script("roadmapDetails", preferred_lang)}
1315
+ </body>
1316
+ </html>
1317
+ """
1318
+
1319
+
1320
+ def render_inline_details(
1321
+ nodes: list[dict[str, str]],
1322
+ cards: list[dict[str, str]],
1323
+ case_anchor_map: dict[str, str],
1324
+ ) -> str:
1325
+ detail_items = human_detail_node_items(nodes)
1326
+ node_sections = "\n".join(render_node_detail(node, i, cards, case_anchor_map) for i, node in detail_items)
1327
+ if not node_sections:
1328
+ node_sections = '<section class="detail-card" data-i18n="emptyRoadmap">No roadmap nodes recorded yet.</section>'
1329
+ return f"""<section class="inline-details" hidden data-inline-details aria-label="Roadmap details">
1330
+ <h2 data-i18n="roadmapDetails">Roadmap Details</h2>
1331
+ {node_sections}
1332
+ <a class="inline-top" href="#roadmap-overview" data-detail-close data-i18n="backToRoadmap">Back to roadmap</a>
1333
+ </section>"""
1334
+
1335
+
1336
+ def parse_bullets(text: str) -> list[tuple[str, str]]:
1337
+ items: list[tuple[str, str]] = []
1338
+ for line in text.splitlines():
1339
+ stripped = line.strip()
1340
+ if not stripped.startswith("- "):
1341
+ continue
1342
+ body = stripped[2:]
1343
+ if ":" in body:
1344
+ key, value = body.split(":", 1)
1345
+ items.append((key.strip(), value.strip()))
1346
+ else:
1347
+ items.append(("Item", body.strip()))
1348
+ return items
1349
+
1350
+
1351
+ def parse_roadmap_nodes(text: str) -> list[dict[str, str]]:
1352
+ nodes: list[dict[str, str]] = []
1353
+ current: dict[str, str] | None = None
1354
+ for line in text.splitlines():
1355
+ if line.startswith("### "):
1356
+ if current:
1357
+ nodes.append(current)
1358
+ title = line[4:].strip()
1359
+ current = {"title": title}
1360
+ continue
1361
+ if current is None:
1362
+ continue
1363
+ stripped = line.strip()
1364
+ if stripped.startswith("- ") and ":" in stripped:
1365
+ key, value = stripped[2:].split(":", 1)
1366
+ normalized_key = canonical_node_key(key)
1367
+ if normalized_key in {"id", "node id"}:
1368
+ continue
1369
+ if normalized_key == "title" and "title" in current:
1370
+ continue
1371
+ current[normalized_key] = value.strip()
1372
+ if current:
1373
+ nodes.append(current)
1374
+ if nodes:
1375
+ return nodes
1376
+ return parse_loose_roadmap_nodes(text)
1377
+
1378
+
1379
+ def canonical_node_key(key: str) -> str:
1380
+ normalized = key.strip().lower().replace("_", " ")
1381
+ aliases = {
1382
+ "id": "id",
1383
+ "node id": "id",
1384
+ "node": "id",
1385
+ "title": "title",
1386
+ "name": "title",
1387
+ "display title": "display title",
1388
+ "display": "display title",
1389
+ "human title": "display title",
1390
+ "user title": "display title",
1391
+ "date": "date",
1392
+ "status": "status",
1393
+ "level": "level",
1394
+ "branch": "branch",
1395
+ "route": "branch",
1396
+ "parent": "parent",
1397
+ "task": "task",
1398
+ "user request": "user request",
1399
+ "user prompt": "user request",
1400
+ "user input": "user request",
1401
+ "user question": "user request",
1402
+ "user asked": "user request",
1403
+ "progress": "progress summary",
1404
+ "current progress": "progress summary",
1405
+ "progress summary": "progress summary",
1406
+ "human progress": "progress summary",
1407
+ "method": "method summary",
1408
+ "method summary": "method summary",
1409
+ "human method": "method summary",
1410
+ "approach": "method summary",
1411
+ "outcome": "outcome",
1412
+ "summary": "outcome",
1413
+ "decision": "decision / reason",
1414
+ "reason": "decision / reason",
1415
+ "decision / reason": "decision / reason",
1416
+ "avoid going back": "avoid going back",
1417
+ "next": "next",
1418
+ "linked bad cases": "linked bad cases",
1419
+ "bad cases": "linked bad cases",
1420
+ "test chain": "test chain",
1421
+ "tests": "test chain",
1422
+ }
1423
+ return aliases.get(normalized, normalized)
1424
+
1425
+
1426
+ def split_loose_field(body: str) -> tuple[str, str] | None:
1427
+ if ":" not in body:
1428
+ return None
1429
+ key, value = body.split(":", 1)
1430
+ key = canonical_node_key(key)
1431
+ if key not in {
1432
+ "id",
1433
+ "title",
1434
+ "display title",
1435
+ "date",
1436
+ "status",
1437
+ "level",
1438
+ "branch",
1439
+ "parent",
1440
+ "task",
1441
+ "user request",
1442
+ "progress summary",
1443
+ "method summary",
1444
+ "outcome",
1445
+ "decision / reason",
1446
+ "avoid going back",
1447
+ "next",
1448
+ "linked bad cases",
1449
+ "test chain",
1450
+ }:
1451
+ return None
1452
+ return key, value.strip()
1453
+
1454
+
1455
+ def loose_node_title(node: dict[str, str]) -> str:
1456
+ title = node.get("title", "").strip()
1457
+ identifier = node.pop("id", "").strip()
1458
+ if not title:
1459
+ title = identifier or "Untitled roadmap node"
1460
+ elif identifier and not title.startswith(identifier):
1461
+ title = f"{identifier}: {title}"
1462
+ return title
1463
+
1464
+
1465
+ def commit_loose_node(nodes: list[dict[str, str]], current: dict[str, str] | None) -> None:
1466
+ if not current:
1467
+ return
1468
+ if "title" not in current and "id" not in current:
1469
+ return
1470
+ current["title"] = loose_node_title(current)
1471
+ nodes.append(current)
1472
+
1473
+
1474
+ def parse_loose_roadmap_nodes(text: str) -> list[dict[str, str]]:
1475
+ nodes: list[dict[str, str]] = []
1476
+ current: dict[str, str] | None = None
1477
+ for line in text.splitlines():
1478
+ stripped = line.strip()
1479
+ if not stripped:
1480
+ continue
1481
+ body = ""
1482
+ if stripped.startswith("- "):
1483
+ body = stripped[2:].strip()
1484
+ elif current and re.match(r"^[A-Za-z][A-Za-z _/-]+:\s+", stripped):
1485
+ body = stripped
1486
+ else:
1487
+ continue
1488
+
1489
+ node_line = re.match(r"^(NODE-\d{8}-\d+)\s*:\s*(.+)$", body)
1490
+ if node_line:
1491
+ commit_loose_node(nodes, current)
1492
+ current = {"id": node_line.group(1), "title": node_line.group(2).strip()}
1493
+ continue
1494
+
1495
+ field = split_loose_field(body)
1496
+ if not field:
1497
+ continue
1498
+ key, value = field
1499
+ if key == "id" and re.search(r"NODE-\d{8}-\d+", value):
1500
+ commit_loose_node(nodes, current)
1501
+ current = {"id": re.search(r"NODE-\d{8}-\d+", value).group(0)}
1502
+ trailing = value.replace(current["id"], "", 1).strip(" -:")
1503
+ if trailing:
1504
+ current["title"] = trailing
1505
+ continue
1506
+ if current is None:
1507
+ current = {}
1508
+ current[key] = value
1509
+ commit_loose_node(nodes, current)
1510
+ return nodes
1511
+
1512
+
1513
+ def node_id(node: dict[str, str]) -> str:
1514
+ title = node.get("title", "")
1515
+ match = re.match(r"(NODE-\d{8}-\d+)", title)
1516
+ return match.group(1) if match else title.split(":", 1)[0].strip()
1517
+
1518
+
1519
+ def branch_name(node: dict[str, str]) -> str:
1520
+ return human_text(node.get("branch", "Main")).strip() or "Main"
1521
+
1522
+
1523
+ def node_level(node: dict[str, str]) -> str:
1524
+ level = node.get("level", "major").strip().lower()
1525
+ if level in {"checkpoint", "minor", "detail"}:
1526
+ return "checkpoint"
1527
+ return "major"
1528
+
1529
+
1530
+ def human_level(node: dict[str, str]) -> str:
1531
+ return "Checkpoint" if node_level(node) == "checkpoint" else "Major"
1532
+
1533
+
1534
+ def group_nodes_by_branch(nodes: list[dict[str, str]]) -> list[tuple[str, list[tuple[int, dict[str, str]]]]]:
1535
+ groups: list[tuple[str, list[tuple[int, dict[str, str]]]]] = []
1536
+ index: dict[str, int] = {}
1537
+ for number, node in enumerate(nodes, 1):
1538
+ branch = branch_name(node)
1539
+ key = branch.lower()
1540
+ if key not in index:
1541
+ index[key] = len(groups)
1542
+ groups.append((branch, []))
1543
+ groups[index[key]][1].append((number, node))
1544
+ return groups
1545
+
1546
+
1547
+ def display_items_for_route(items: list[tuple[int, dict[str, str]]]) -> list[tuple[int, dict[str, str]]]:
1548
+ major_items = [(number, node) for number, node in items if node_level(node) == "major"]
1549
+ return major_items or items
1550
+
1551
+
1552
+ def human_detail_node_items(nodes: list[dict[str, str]]) -> list[tuple[int, dict[str, str]]]:
1553
+ items = list(enumerate(nodes, 1))
1554
+ major_items = [(number, node) for number, node in items if node_level(node) == "major"]
1555
+ return major_items or items
1556
+
1557
+
1558
+ def linked_cases_for_detail_items(
1559
+ items: list[tuple[int, dict[str, str]]],
1560
+ cards: list[dict[str, str]],
1561
+ ) -> list[dict[str, str]]:
1562
+ linked: list[dict[str, str]] = []
1563
+ seen: set[str] = set()
1564
+ for _, node in items:
1565
+ for card in bad_cases_for_node(node, cards):
1566
+ title = card.get("title", "")
1567
+ if title in seen:
1568
+ continue
1569
+ seen.add(title)
1570
+ linked.append(card)
1571
+ return linked
1572
+
1573
+
1574
+ def external_parent_id(
1575
+ branch: str,
1576
+ items: list[tuple[int, dict[str, str]]],
1577
+ node_lookup: dict[str, dict[str, str]],
1578
+ ) -> str:
1579
+ if branch.strip().lower() == "main":
1580
+ return ""
1581
+ for _, node in items:
1582
+ raw_parent = node.get("parent", "").strip()
1583
+ if raw_parent and raw_parent.lower() not in {"none", "n/a", "null"}:
1584
+ match = re.search(r"NODE-\d{8}-\d+", raw_parent)
1585
+ candidate = match.group(0) if match else raw_parent
1586
+ parent_node = node_lookup.get(candidate, {})
1587
+ if branch_name(parent_node).strip().lower() != branch.strip().lower():
1588
+ return candidate
1589
+ return ""
1590
+
1591
+
1592
+ def build_visible_route_positions(
1593
+ route_groups: list[tuple[str, list[tuple[int, dict[str, str]]]]],
1594
+ ) -> tuple[dict[str, tuple[str, int]], dict[str, tuple[str, int]], dict[str, list[tuple[int, str, int]]]]:
1595
+ visible_positions: dict[str, tuple[str, int]] = {}
1596
+ source_positions: dict[str, tuple[str, int]] = {}
1597
+ visible_by_branch: dict[str, list[tuple[int, str, int]]] = {}
1598
+ for branch, items in route_groups:
1599
+ branch_key = branch.lower()
1600
+ visible_by_branch[branch_key] = []
1601
+ for source_number, node in items:
1602
+ source_positions[node_id(node)] = (branch_key, source_number)
1603
+ for display_index, (source_number, node) in enumerate(display_items_for_route(items)):
1604
+ nid = node_id(node)
1605
+ visible_positions[nid] = (branch_key, display_index)
1606
+ visible_by_branch[branch_key].append((source_number, nid, display_index))
1607
+ return visible_positions, source_positions, visible_by_branch
1608
+
1609
+
1610
+ def parent_visible_offset(
1611
+ parent_id: str,
1612
+ visible_positions: dict[str, tuple[str, int]],
1613
+ source_positions: dict[str, tuple[str, int]],
1614
+ visible_by_branch: dict[str, list[tuple[int, str, int]]],
1615
+ ) -> int:
1616
+ if parent_id in visible_positions:
1617
+ return visible_positions[parent_id][1]
1618
+ parent_source = source_positions.get(parent_id)
1619
+ if not parent_source:
1620
+ return 0
1621
+ parent_branch, parent_number = parent_source
1622
+ candidates = [
1623
+ display_index
1624
+ for source_number, _, display_index in visible_by_branch.get(parent_branch, [])
1625
+ if source_number <= parent_number
1626
+ ]
1627
+ return max(candidates) if candidates else 0
1628
+
1629
+
1630
+ def parent_visible_anchor_id(
1631
+ parent_id: str,
1632
+ visible_positions: dict[str, tuple[str, int]],
1633
+ source_positions: dict[str, tuple[str, int]],
1634
+ visible_by_branch: dict[str, list[tuple[int, str, int]]],
1635
+ ) -> str:
1636
+ if parent_id in visible_positions:
1637
+ return parent_id
1638
+ parent_source = source_positions.get(parent_id)
1639
+ if not parent_source:
1640
+ return ""
1641
+ parent_branch, parent_number = parent_source
1642
+ candidates = [
1643
+ (source_number, display_index, nid)
1644
+ for source_number, nid, display_index in visible_by_branch.get(parent_branch, [])
1645
+ if source_number <= parent_number
1646
+ ]
1647
+ if not candidates:
1648
+ return ""
1649
+ return max(candidates, key=lambda item: (item[0], item[1]))[2]
1650
+
1651
+
1652
+ def build_route_offsets(
1653
+ route_groups: list[tuple[str, list[tuple[int, dict[str, str]]]]],
1654
+ node_lookup: dict[str, dict[str, str]],
1655
+ ) -> dict[str, int]:
1656
+ visible_positions, source_positions, visible_by_branch = build_visible_route_positions(route_groups)
1657
+ offsets: dict[str, int] = {}
1658
+ for branch, items in route_groups:
1659
+ parent_id = external_parent_id(branch, items, node_lookup)
1660
+ offsets[branch.lower()] = (
1661
+ parent_visible_offset(parent_id, visible_positions, source_positions, visible_by_branch)
1662
+ if parent_id
1663
+ else 0
1664
+ )
1665
+ return offsets
1666
+
1667
+
1668
+ def build_route_parent_anchors(
1669
+ route_groups: list[tuple[str, list[tuple[int, dict[str, str]]]]],
1670
+ node_lookup: dict[str, dict[str, str]],
1671
+ ) -> dict[str, tuple[str, str]]:
1672
+ visible_positions, source_positions, visible_by_branch = build_visible_route_positions(route_groups)
1673
+ anchors: dict[str, tuple[str, str]] = {}
1674
+ for branch, items in route_groups:
1675
+ parent_id = external_parent_id(branch, items, node_lookup)
1676
+ anchor_id = (
1677
+ parent_visible_anchor_id(parent_id, visible_positions, source_positions, visible_by_branch)
1678
+ if parent_id
1679
+ else ""
1680
+ )
1681
+ anchors[branch.lower()] = (parent_id, anchor_id)
1682
+ return anchors
1683
+
1684
+
1685
+ def build_route_depths(
1686
+ route_groups: list[tuple[str, list[tuple[int, dict[str, str]]]]],
1687
+ node_lookup: dict[str, dict[str, str]],
1688
+ ) -> dict[str, int]:
1689
+ group_map = {branch.lower(): (branch, items) for branch, items in route_groups}
1690
+ memo: dict[str, int] = {}
1691
+
1692
+ def depth_for(branch_key: str, seen: set[str] | None = None) -> int:
1693
+ if branch_key in memo:
1694
+ return memo[branch_key]
1695
+ seen = set(seen or set())
1696
+ if branch_key in seen or branch_key == "main":
1697
+ memo[branch_key] = 0
1698
+ return 0
1699
+ seen.add(branch_key)
1700
+ branch, items = group_map.get(branch_key, ("", []))
1701
+ parent_id = external_parent_id(branch, items, node_lookup) if branch else ""
1702
+ parent_node = node_lookup.get(parent_id, {})
1703
+ parent_branch_key = branch_name(parent_node).lower() if parent_node else ""
1704
+ if not parent_branch_key or parent_branch_key == branch_key:
1705
+ memo[branch_key] = 0
1706
+ else:
1707
+ memo[branch_key] = depth_for(parent_branch_key, seen) + 1
1708
+ return memo[branch_key]
1709
+
1710
+ for branch, _ in route_groups:
1711
+ depth_for(branch.lower())
1712
+ return memo
1713
+
1714
+
1715
+ def route_color_vars(depth: int) -> str:
1716
+ palette = [
1717
+ ("#2f7d63", "#dff1e7", "#c2d8c8"),
1718
+ ("#197a8a", "#d8eef2", "#9fc9d2"),
1719
+ ("#2b55b3", "#dfe8fb", "#adc2eb"),
1720
+ ("#6542b8", "#e9e2fb", "#c4b4ec"),
1721
+ ]
1722
+ accent, soft, line = palette[min(max(depth, 0), len(palette) - 1)]
1723
+ return f"--route-accent: {accent}; --route-soft: {soft}; --route-line: {line};"
1724
+
1725
+
1726
+ def route_slug(branch: str) -> str:
1727
+ slug = re.sub(r"[^a-z0-9]+", "-", branch.lower()).strip("-")
1728
+ return slug or "main"
1729
+
1730
+
1731
+ def human_title(title: str) -> str:
1732
+ return re.sub(r"^(?:NODE|BC)-\d{8}-\d+:\s*", "", title).strip() or title
1733
+
1734
+
1735
+ def node_display_title(node: dict[str, str], fallback: str) -> str:
1736
+ """Return the short human-facing title, keeping source titles for agent context."""
1737
+ return human_title(node.get("display title", "").strip() or node.get("title", "").strip() or fallback)
1738
+
1739
+
1740
+ def human_text(text: str) -> str:
1741
+ parts = re.split(r"(`[^`]*`)", text)
1742
+ cleaned: list[str] = []
1743
+ for part in parts:
1744
+ if part.startswith("`") and part.endswith("`"):
1745
+ cleaned.append(part)
1746
+ continue
1747
+ part = re.sub(r"CTX-\d{8}-[\w-]+", "this task", part)
1748
+ part = re.sub(r"NODE-\d{8}-\d+", "a roadmap node", part)
1749
+ part = re.sub(r"BC-\d{8}-\d+", "a linked bad case", part)
1750
+ cleaned.append(part)
1751
+ return "".join(cleaned)
1752
+
1753
+
1754
+ ZH_TEXT: dict[str, str] = {
1755
+ "Main": "主线",
1756
+ "Roadmap UX": "路线图体验",
1757
+ "Documentation": "文档支线",
1758
+ "Identify Superpowers as workflow baseline": "将 Superpowers 作为工作流基线",
1759
+ "Create initial bad-case regression guard": "创建初始 bad case 回归防线",
1760
+ "Rename and widen scope to Context Guard": "重命名并扩展为 Context Guard",
1761
+ "Add dynamic task index and interruption handling": "添加动态任务索引和中断处理",
1762
+ "Add folder-scoped roadmap model": "添加文件夹级路线图模型",
1763
+ "Export roadmap as HTML": "将路线图导出为 HTML",
1764
+ "Add direct show-roadmap behavior and global fallback": "添加直接展示路线图和全局兜底",
1765
+ "Enforce concise context and roadmap display": "强制精简 context 和路线图展示",
1766
+ "Switch roadmap to three horizontal tracks": "切换为三条横向轨道",
1767
+ "Keep roadmap export files stable": "保持路线图导出文件稳定",
1768
+ "Separate human roadmap view from Codex context sources": "区分人类路线图视图和 Codex context 源",
1769
+ "Hide internal IDs in human roadmap": "在人类路线图中隐藏内部 ID",
1770
+ "Split roadmap into compact overview and detail page": "拆分路线图为简洁概览和详情页",
1771
+ "Replace roadmap metadata text with visual cues": "用视觉提示替代路线图元数据文字",
1772
+ "Add tag chips to roadmap display": "为路线图添加标签胶囊",
1773
+ "Add emoji cues to tag chips": "为标签胶囊添加表情提示",
1774
+ "Support branch-aware roadmap routes": "支持分支感知的路线图路线",
1775
+ "Coarsen main route node granularity": "放粗主路线节点粒度",
1776
+ "Add multilingual roadmap display": "添加多语言路线图显示",
1777
+ "Add goal-mode context checkpoints": "添加 goal 模式 context 检查点",
1778
+ "Move lane titles to left label column": "将轨道标题移到左侧标签列",
1779
+ "Use vertical labels and Chinese record text": "使用竖排标签和中文记录文本",
1780
+ "Add repository README": "添加仓库 README",
1781
+ "Rebalance roadmap routes and numbering": "重整路线图路线和编号",
1782
+ "Explore roadmap visual theme options": "探索路线图视觉主题选项",
1783
+ "Use Botanical and route-focused drilldown": "使用 Botanical 和路线聚焦详情",
1784
+ "Main base": "主线起点",
1785
+ "Main later checkpoint": "主线后续检查点",
1786
+ "No source user request recorded for this historical node.": "这个历史节点没有记录用户原始请求。",
1787
+ "Bad cases would only live in chat": "Bad case 只存在聊天里",
1788
+ "Scope drift toward scripting every bad case": "范围漂移到为每个 bad case 写脚本",
1789
+ "Interrupted design context could be lost": "被中断的设计 context 可能丢失",
1790
+ "Context bound to thread instead of folder": "Context 绑定在线程而不是文件夹",
1791
+ "Markdown roadmap is uncomfortable for humans": "Markdown 路线图不适合人类阅读",
1792
+ "Roadmap request answered with instructions instead of display": "路线图请求被回答成说明而不是直接展示",
1793
+ "Context Guard may not activate without explicit mention": "未显式提及时 Context Guard 可能不会激活",
1794
+ "Roadmap nodes were not recorded during this skill's development": "开发这个 skill 时没有记录路线节点",
1795
+ "Context records could become too verbose": "Context 记录可能变得过于冗长",
1796
+ "Roadmap display could use the wrong mental model": "路线图展示可能使用错误模型",
1797
+ "Roadmap files could accumulate endlessly": "路线图文件可能无限堆积",
1798
+ "HTML roadmap could be mistaken for Codex context source": "HTML 路线图可能被误当成 Codex context 源",
1799
+ "Human roadmap exposes internal IDs": "人类路线图暴露内部 ID",
1800
+ "User roadmap overview could become too verbose": "用户路线图概览可能过于冗长",
1801
+ "Roadmap metadata labels create visual noise": "路线图元数据标签造成视觉噪声",
1802
+ "User roadmap lacks tag semantics and feels stiff": "用户路线图缺少标签语义且显得生硬",
1803
+ "Roadmap tags lack emoji cues": "路线图标签缺少表情提示",
1804
+ "Roadmap assumes a single main route": "路线图假设只有一条主线",
1805
+ "Main route cards are too granular": "主路线卡片粒度过细",
1806
+ "Roadmap chrome only supports English": "路线图界面只支持英文",
1807
+ "Goal-mode work may lag context updates": "Goal 模式工作可能滞后更新 context",
1808
+ "Lane titles repeat inside every node card": "轨道标题在每个节点卡片里重复",
1809
+ "Lane label column is not vertical": "轨道标签列没有竖排",
1810
+ "Chinese mode leaves record content in English": "中文模式下记录内容仍是英文",
1811
+ "Visible roadmap numbering skips hidden checkpoints": "路线图可见编号跳过隐藏检查点",
1812
+ "forked from": "从",
1813
+ "Skill may not activate without explicit mention": "未显式提及时 skill 可能不会激活",
1814
+ "Roadmap nodes were not recorded during this skill's own development": "开发这个 skill 时没有记录路线节点",
1815
+ "Reviewed Superpowers and installed it for stronger engineering workflow discipline.": "已查看 Superpowers 并安装,用于强化工程工作流纪律。",
1816
+ "Verified Superpowers symlink and skill discovery paths.": "已验证 Superpowers 符号链接和 skill 发现路径。",
1817
+ "Created a skill to record bad cases, fixes, verification methods, and recurrence analysis.": "创建 skill,用于记录 bad case、修复方法、验证方法和复现分析。",
1818
+ "Ran `quick_validate.py` and passed.": "已运行 `quick_validate.py` 并通过。",
1819
+ "Renamed skill to `context-guard` and changed default storage to `.codex/context/`.": "将 skill 重命名为 `context-guard`,并把默认存储改为 `.codex/context/`。",
1820
+ "Validated skill after rename.": "重命名后已验证 skill。",
1821
+ "Added `.codex/context/index.md`, task folders, parked/resume-candidate states, and resume prompts.": "添加 `.codex/context/index.md`、任务文件夹、停放/候选恢复状态和恢复提示。",
1822
+ "Added hooks for `UserPromptSubmit` and `Stop`; dry run passed.": "添加 `UserPromptSubmit` 和 `Stop` hooks,并通过 dry run。",
1823
+ "Added folder-level `.codex/context/roadmap.md`, route nodes, bad-case links, frequency tags, and test-chain notes.": "添加文件夹级 `.codex/context/roadmap.md`、路线节点、bad case 链接、频率标签和测试链路备注。",
1824
+ "Plugin validation and hook dry runs passed.": "插件验证和 hook dry run 已通过。",
1825
+ "Changed roadmap export from Markdown to a human-friendly single-file HTML view.": "将路线图导出从 Markdown 改为更适合人类阅读的单文件 HTML 视图。",
1826
+ "`context_guard.py export-roadmap` generated valid HTML with Quick Scan, Main Route, and Bad Cases.": "`context_guard.py export-roadmap` 已生成包含 Quick Scan、主要路线和 Bad Case 的有效 HTML。",
1827
+ "Added `show-roadmap`, updated skill instructions so Codex displays HTML directly, and added global AGENTS/hook fallback for cases where the skill is not explicitly invoked.": "添加 `show-roadmap`,更新 skill 说明让 Codex 直接展示 HTML,并加入全局 AGENTS/hook 兜底以处理未显式调用 skill 的情况。",
1828
+ "`show-roadmap` generated file URL; global hook dry run initialized context and detected task switch/bad case prompts.": "`show-roadmap` 已生成文件 URL;全局 hook dry run 能初始化 context 并识别任务切换或 bad case 提示。",
1829
+ "Added a conciseness contract and compact HTML roadmap defaults.": "添加精简契约和紧凑 HTML 路线图默认规则。",
1830
+ "Compact HTML assertion passed; skill/plugin validation passed; pushed commit `5ca87e2`.": "紧凑 HTML 断言通过,skill/plugin 验证通过,并推送 commit `5ca87e2`。",
1831
+ "Context could drift into a transcript instead of key nodes and bad cases.": "Context 可能变成流水账,而不是关键节点和 bad case。",
1832
+ "Let templates invite full decisions, commands, and details into every node.": "模板会把完整决策、命令和细节引入每个节点。",
1833
+ "Conciseness was a preference, not a hard contract.": "精简只是偏好,不是强约束。",
1834
+ "Added concise-context rules, compact templates, and folded HTML details.": "添加精简 context 规则、紧凑模板和折叠 HTML 详情。",
1835
+ "Run compact HTML assertion and skill/plugin validation.": "运行紧凑 HTML 断言和 skill/plugin 校验。",
1836
+ "Solved bad cases could be forgotten after the conversation moved on.": "已解决的 bad case 可能在对话推进后被遗忘。",
1837
+ "Continue development without a project-level bad-case register.": "在没有项目级 bad case 登记表的情况下继续开发。",
1838
+ "No durable folder-scoped memory.": "缺少持久的文件夹级记忆。",
1839
+ "Added `.codex/context/bad-cases.md` and task-local bad-case support.": "添加 `.codex/context/bad-cases.md` 和任务内 bad case 支持。",
1840
+ "The skill direction drifted toward wrapping every bad case in scripts.": "skill 方向漂移成把每个 bad case 都封装成脚本。",
1841
+ "Treat verification reuse as script generation by default.": "默认把复用验证等同于生成脚本。",
1842
+ "Overemphasis on automation instead of context maintenance.": "过度强调自动化,而忽略 context 维护。",
1843
+ "Reworded skill so context is primary and scripts are optional durable guards.": "重写 skill 说明,明确 context 是核心,脚本只是可选的持久防线。",
1844
+ "A design discussion could be interrupted by an urgent bug and never resumed.": "设计讨论可能被紧急 bug 打断,并且之后没有恢复。",
1845
+ "User switches to an unrelated urgent issue mid-design.": "用户在设计过程中切换到不相关的紧急问题。",
1846
+ "No parked task queue.": "缺少可停放的任务队列。",
1847
+ "Added dynamic index states: current, parked, resume-candidate, done, archived.": "添加动态索引状态:当前、已停放、候选恢复、完成、归档。",
1848
+ "Context could be tied to a thread even though Codex work is folder-based.": "Context 可能绑定在线程上,但 Codex 工作实际以文件夹为边界。",
1849
+ "Open a different thread in the same folder.": "在同一个文件夹中打开另一个线程。",
1850
+ "No explicit folder-scoped context root.": "缺少明确的文件夹级 context 根目录。",
1851
+ "Defined `.codex/context/` as folder-scoped and added SessionStart initialization.": "定义 `.codex/context/` 为文件夹级目录,并添加 SessionStart 初始化。",
1852
+ "Markdown route map is agent-readable but not pleasant for human roadmap review.": "Markdown 路线图适合 agent 读取,但不适合人类舒服地查看路线。",
1853
+ "User asks to see roadmap and receives Markdown-style output.": "用户要求查看路线图时收到 Markdown 风格输出。",
1854
+ "Export format optimized for agent, not human.": "导出格式偏向 agent,而不是人类阅读。",
1855
+ "Added HTML export with Quick Scan, Main Route, and Bad Cases columns.": "添加包含快速扫描、主要路线和 Bad Case 列的 HTML 导出。",
1856
+ "User expected `$context-guard 展示 roadmap` to display roadmap directly, not explain commands.": "用户期望 `$context-guard 展示 roadmap` 直接展示路线图,而不是解释命令。",
1857
+ "User asks how to view roadmap.": "用户询问如何查看路线图。",
1858
+ "Added `show-roadmap` and skill instructions to open/display generated HTML.": "添加 `show-roadmap` 和 skill 说明,用于打开或展示生成的 HTML。",
1859
+ "Without `$context-guard`, Codex might not load the skill and might skip context intake/checkpoint.": "没有 `$context-guard` 时,Codex 可能不会加载 skill,从而跳过 context 读取和检查点。",
1860
+ "User asks a context-worthy question without explicit skill mention.": "用户提出需要 context 的问题,但没有显式提到 skill。",
1861
+ "Skill body only loads after activation; implicit activation is not guaranteed.": "skill 内容只有激活后才会加载,隐式激活并不可靠。",
1862
+ "Added global AGENTS fallback protocol and user-level hooks.": "添加全局 AGENTS 兜底协议和用户级 hooks。",
1863
+ "The user asked why current skill development had no roadmap nodes; `.codex/context/roadmap.md` was empty.": "用户询问为什么当前 skill 开发没有路线节点;`.codex/context/roadmap.md` 为空。",
1864
+ "Run `show-roadmap` after developing the skill and inspect empty roadmap.": "开发 skill 后运行 `show-roadmap` 并看到空路线图。",
1865
+ "Context Guard was created late and not retroactively applied to the ongoing development process.": "Context Guard 创建得较晚,没有回填到正在进行的开发过程。",
1866
+ "Backfilled roadmap nodes and bad-case register for the current skill development.": "为当前 skill 开发回填路线节点和 bad case 登记。",
1867
+ "Open `roadmap.html`, switch to Chinese, and inspect main route, bad-case, test-chain, and detail text.": "打开 `roadmap.html`,切换到中文,并检查主路线、bad case、测试链路和详情文本。",
1868
+ "Vertical label and Chinese record assertion checks localized overview records and localized detail records.": "竖排标签和中文记录断言检查本地化概览记录和本地化详情记录。",
1869
+ "Roadmap display now uses node columns with Main Route, Bad Cases, and Test Chain lanes.": "路线图现在使用节点列,并包含主要路线、Bad Case、测试链路三条轨道。",
1870
+ "Roadmap could appear as a three-column dashboard instead of three horizontal tracks.": "路线图可能显示成三列仪表盘,而不是三条横向轨道。",
1871
+ "Generate HTML with separate Quick Scan/Main Route/Bad Case columns.": "生成带独立 Quick Scan、主要路线和 Bad Case 列的 HTML。",
1872
+ "Display model did not encode horizontal mainline plus vertical node-linked lanes.": "展示模型没有表达横向主线和按节点竖向关联的轨道。",
1873
+ "Render node columns with Main Route, Bad Cases, and Test Chain lanes.": "渲染节点列,并包含主要路线、Bad Case 和测试链路轨道。",
1874
+ "Three-track HTML assertion passed; generated roadmap had 9 main/bad-case/test-chain lane sets and no old layout; pushed commit `4c31abd`.": "三轨 HTML 断言通过;生成的路线图包含 9 组主线/bad case/测试链路线,且不再出现旧布局;已推送 commit `4c31abd`。",
1875
+ "Roadmap display now targets stable HTML files instead of timestamped exports.": "路线图展示现在写入稳定 HTML 文件,而不是时间戳导出文件。",
1876
+ "Stable export assertion passed; current folder has stable HTML files and no timestamped roadmap HTML; pushed commit `13be025`; later route added stable details page.": "稳定导出断言通过;当前文件夹只有稳定 HTML 文件,没有时间戳路线图 HTML;已推送 commit `13be025`;后续路线加入了稳定详情页。",
1877
+ "Clarified that `roadmap.html` is only the user-facing view, while Codex reads source context files.": "明确 `roadmap.html` 只是用户视图,Codex 读取源 context 文件。",
1878
+ "Artifact-role assertion passed; skill/plugin validation passed; generated HTML/Markdown carry role markers; pushed commit `ef9a18f`.": "产物角色断言通过,skill/plugin 验证通过,生成的 HTML/Markdown 都带有角色标记;已推送 commit `ef9a18f`。",
1879
+ "User-facing roadmap now shows concise natural-language node and bad-case labels.": "面向用户的路线图现在显示简洁自然语言节点和 bad case 标签。",
1880
+ "Human-label assertion passed; real HTML contains no internal IDs while Markdown keeps them; pushed commit `4049b32`.": "人类标签断言通过;真实 HTML 不含内部 ID,Markdown 保留内部 ID;已推送 commit `4049b32`。",
1881
+ "Roadmap overview now shows sparse labels and links detailed fields to a stable detail page.": "路线图概览现在只显示精简标签,并把详细字段链接到稳定详情页。",
1882
+ "Compact overview assertion passed; real HTML links to detail page and hides verbose fields; pushed commit `f5fb2b2`.": "精简概览断言通过;真实 HTML 链接到详情页,并隐藏冗长字段;已推送 commit `f5fb2b2`。",
1883
+ "Compact overview assertion checks no verbose fields or Quick Scan panel on the overview and confirms detail links exist.": "精简概览断言检查概览页没有冗长字段或 Quick Scan 面板,并确认详情链接存在。",
1884
+ "User-facing roadmap now uses color markers for status/frequency and hides empty tag fallback text.": "面向用户的路线图现在用颜色标记表示状态/频率,并隐藏空标签兜底文本。",
1885
+ "Visual cue assertion checks no raw metadata words and confirms status markers exist; pushed commit `bd19ce6`.": "视觉提示断言检查无原始元数据文字,并确认状态标记存在;已推送 commit `bd19ce6`。",
1886
+ "Bad-case tags now render as compact colored chips in overview and detail views.": "Bad case 标签现在在概览和详情中渲染为紧凑彩色胶囊。",
1887
+ "Tag rendering assertion checks overview tags, detail tags, visual tag classes, and no fallback tag text; pushed commit `0d238cb`.": "标签渲染断言检查概览标签、详情标签、视觉标签类,并确认无兜底标签文本;已推送 commit `0d238cb`。",
1888
+ "Tag chips now include small emoji cues mapped from tag semantics.": "标签胶囊现在包含按标签语义映射的小表情提示。",
1889
+ "Emoji tag assertion checks emoji spans, semantic emoji mappings, and no fallback tag text; pushed commit `48e21b1`.": "表情标签断言检查 emoji 片段、语义映射和无兜底标签文本;已推送 commit `48e21b1`。",
1890
+ "Roadmap HTML now groups nodes by route branch, so forked or parallel mainlines do not collapse into one line.": "路线图 HTML 现在按路线分支分组,分叉或并行主线不会被压成一条线。",
1891
+ "Branch rendering assertion checks route groups, branch labels, separate horizontal grids, parent route detail, hidden internal IDs, and real HTML export; pushed commit `61506ca`.": "分支渲染断言检查路线组、分支标签、独立横向网格、父路线详情、隐藏内部 ID 和真实 HTML 导出;已推送 commit `61506ca`。",
1892
+ "Roadmap overview now shows only major milestones as main route cards and folds smaller updates into details.": "路线图概览现在只把重要里程碑显示为主路线卡片,并将较小更新折叠到详情中。",
1893
+ "Major-node granularity assertion checks checkpoint hiding, compact checkpoint summary, detail retention, and level labels; pushed commit `5af24f5`.": "主节点粒度断言检查隐藏 checkpoint、紧凑 checkpoint 摘要、详情保留和等级标签;已推送 commit `5af24f5`。",
1894
+ "Roadmap overview and details now support Chinese/English UI chrome in the same stable HTML files.": "路线图概览和详情现在在同一组稳定 HTML 文件中支持中英文界面。",
1895
+ "i18n assertion checks language toggles, Chinese/English labels, URL parameter support, and details page labels; pushed commit `085479a`.": "i18n 断言检查语言切换、中英文标签、URL 参数支持和详情页标签;已推送 commit `085479a`。",
1896
+ "Context Guard now tells Codex to keep roadmap and bad-case memory updated during goal-mode work.": "Context Guard 现在要求 Codex 在 goal 模式工作中持续更新路线图和 bad case 记忆。",
1897
+ "Goal-mode assertion checks skill rules, `get_goal`/`update_goal` constraints, hook hints, and template maintenance rules; pushed commit `8d9c064`.": "Goal 模式断言检查 skill 规则、`get_goal`/`update_goal` 约束、hook 提示和模板维护规则;已推送 commit `8d9c064`。",
1898
+ "Roadmap overview now renders Main Route, Bad Cases, and Test Chain labels once in a left-side column for each route group.": "路线图概览现在为每个路线组在左侧列中只显示一次主要路线、Bad Case 和测试链路标签。",
1899
+ "Lane header column assertion checks one left label column and no lane labels inside node cards; pushed commit `2169387`.": "轨道标题列断言检查左侧只有一列标题,并且节点卡片内没有轨道标题;已推送 commit `2169387`。",
1900
+ "Roadmap labels use a vertical left column and Chinese mode localizes record titles, summaries, bad cases, and test snippets.": "路线图标签使用左侧竖排列,中文模式会本地化记录标题、摘要、bad case 和测试片段。",
1901
+ "Main overview route is coarser, Roadmap UX and Documentation appear as branch routes, and visible overview numbers are consecutive per route group.": "主概览路线更粗粒度,路线图体验和文档以支线显示,并且每个路线组的可见编号连续。",
1902
+ "Roadmap overview briefly supported visual theme comparison in the same stable HTML file.": "路线图概览曾在同一个稳定 HTML 文件中支持视觉主题对比。",
1903
+ "Roadmap overview now uses Botanical as the only style and switches multi-route displays to route-first drilldown.": "路线图概览现在使用 Botanical 作为唯一样式,并将多路线展示切换为路线优先详情。",
1904
+ "Added a root README explaining Context Guard purpose, installation, hooks, usage, context files, bad-case rules, roadmap model, and verification.": "添加仓库 README,说明 Context Guard 目标、安装、hooks、用法、context 文件、bad case 规则、路线图模型和验证方法。",
1905
+ "Main route starts.": "主线开始。",
1906
+ "Main route references prior main node but is not a branch.": "主线引用前序主节点,但不是支线。",
1907
+ "Consecutive numbering assertion, branch route assertion, real roadmap export, and stable file assertion.": "连续编号断言、分支路线断言、真实路线图导出和稳定文件断言。",
1908
+ "i18n assertion checks language toggles, Chinese/English labels, URL parameter support, and details page labels.": "i18n 断言检查语言切换、中英文标签、URL 参数支持和详情页标签。",
1909
+ "Goal-mode assertion checks skill rules, `get_goal`/`update_goal` constraints, hook hints, and template maintenance rules.": "Goal 模式断言检查 skill 规则、`get_goal`/`update_goal` 约束、hook 提示和模板维护规则。",
1910
+ "Lane header column assertion checks one left label column and no lane labels inside node cards.": "轨道标题列断言检查左侧只有一列标题,并且节点卡片内没有轨道标题。",
1911
+ "Vertical label and Chinese record assertion checks lane writing mode, localized overview records, and localized detail records.": "竖排标签和中文记录断言检查轨道书写方向、概览记录本地化和详情记录本地化。",
1912
+ "Vertical label and Chinese record assertion checks lane writing mode, localized overview records, localized detail records, and stable roadmap files.": "竖排标签和中文记录断言检查轨道书写方向、本地化概览记录、本地化详情记录和稳定路线图文件。",
1913
+ "Vertical label and Chinese record assertion checks `writing-mode: vertical-rl` in generated overview CSS.": "竖排标签和中文记录断言检查生成的概览 CSS 中保留 `writing-mode: vertical-rl`。",
1914
+ "Vertical label and Chinese record assertion passed.": "竖排标签和中文记录断言已通过。",
1915
+ "User said lane labels should be vertical and Chinese mode should show Chinese records, not only Chinese UI chrome.": "用户要求轨道标签竖排,并且中文模式应显示中文记录,而不只是中文 UI 外壳。",
1916
+ "User said 轨道标签 should be vertical and Chinese mode should show Chinese records, not only Chinese UI chrome.": "用户要求轨道标签竖排,并且中文模式应显示中文记录,而不只是中文 UI 外壳。",
1917
+ "Do not treat multilingual roadmap support as only translating static interface labels.": "不要把多语言路线图支持只当成翻译静态界面标签。",
1918
+ "Keep localized record text concise and sourced from the single context projection.": "保持本地化记录文本精简,并来自同一份 context 投影。",
1919
+ "Vertical label and Chinese record assertion checks lane writing mode, localized overview records, localized detail records, and stable roadmap files; pushed commit `18b4209`.": "竖排标签和中文记录断言检查轨道书写方向、本地化概览记录、本地化详情记录和稳定路线图文件;已推送 commit `18b4209`。",
1920
+ "User asked for a README for the current skill so humans can understand and install it from the GitHub repository.": "用户要求为当前 skill 添加 README,让人类可以理解并从 GitHub 仓库安装它。",
1921
+ "Do not leave skill usage knowledge only inside `SKILL.md` or chat history.": "不要把 skill 使用知识只留在 `SKILL.md` 或聊天历史里。",
1922
+ "Keep README updated when install, hook, roadmap, or bad-case behavior changes.": "安装、hook、路线图或 bad-case 行为变化时保持 README 更新。",
1923
+ "README covers purpose, installation, AGENTS/hook setup, usage, context layout, bad-case rules, roadmap model, and verification.": "README 覆盖目标、安装、AGENTS/hook 设置、用法、context 布局、bad-case 规则、路线图模型和验证方法。",
1924
+ "Skill lacked explicit show-roadmap workflow.": "skill 缺少明确的 show-roadmap 工作流。",
1925
+ "Inspect context folder and skill instructions.": "检查 context 文件夹和 skill 说明。",
1926
+ "Read Context Evidence and Guards section.": "阅读 context 证据和守卫规则章节。",
1927
+ "Inspect `.codex/context/index.md` template.": "检查 `.codex/context/index.md` 模板。",
1928
+ "Run context hook session-start dry run.": "运行 context hook 的 session-start dry run。",
1929
+ "Run `context_guard.py export-roadmap` and inspect HTML.": "运行 `context_guard.py export-roadmap` 并检查 HTML。",
1930
+ "Run `context_guard.py show-roadmap`.": "运行 `context_guard.py show-roadmap`。",
1931
+ "Inspect `~/.codex/AGENTS.md` and `~/.codex/hooks.json`; run hook dry run.": "检查 `~/.codex/AGENTS.md` 和 `~/.codex/hooks.json`;运行 hook dry run。",
1932
+ "Re-run `show-roadmap` and confirm nodes appear.": "重新运行 `show-roadmap` 并确认节点出现。",
1933
+ "Run compact HTML assertion plus skill/plugin validators.": "运行紧凑 HTML 断言和 skill/plugin 校验。",
1934
+ "Three-track HTML assertion checks track board, all three lanes, and no old layout class.": "三轨 HTML 断言检查 track board、三条轨道以及无旧布局 class。",
1935
+ "Stable export assertion checks repeated exports return stable paths and no timestamped HTML files exist.": "稳定导出断言检查重复导出会返回稳定路径,并且不会生成带时间戳的 HTML 文件。",
1936
+ "Human-label assertion checks HTML hides internal IDs while keeping natural titles.": "人类标签断言检查 HTML 隐藏内部 ID,同时保留自然语言标题。",
1937
+ "Visual cue assertion checks no raw metadata words in generated human HTML and confirms status markers exist.": "视觉提示断言检查生成的人类 HTML 不显示原始元数据词,并确认状态标记存在。",
1938
+ "Tag rendering assertion checks overview tags, detail tags, visual tag classes, and no fallback tag text.": "标签渲染断言检查概览标签、详情标签、视觉标签类,并确认没有兜底标签文本。",
1939
+ "Emoji tag assertion checks emoji spans, semantic emoji mappings, and no fallback tag text.": "表情标签断言检查 emoji span、语义映射,并确认没有兜底标签文本。",
1940
+ "Overview cards show source node numbers with gaps, such as 3, 5, 7, when checkpoints are hidden.": "隐藏检查点后,概览卡片会显示带跳号的源节点编号,例如 3、5、7。",
1941
+ "The renderer uses the source node index as both the visible overview number and the detail anchor number.": "渲染器同时把源节点序号用作可见概览编号和详情锚点编号。",
1942
+ "Split source detail anchors from visible overview display numbers; overview cards now enumerate display items after checkpoint filtering.": "将源详情锚点和可见概览编号分离;概览卡片现在在过滤检查点后重新编号。",
1943
+ "Red case reproduced visible numbers `1, 3`; green assertion confirms overview shows `1, 2` while links still target source detail anchors.": "红灯用例复现了可见编号 `1, 3`;绿灯断言确认概览显示 `1, 2`,同时链接仍指向源详情锚点。",
1944
+ "The left lane label column renders horizontally and can crowd or crop roadmap cards on narrow screens.": "左侧轨道标签列横向渲染,在窄屏上会挤压或裁切路线图卡片。",
1945
+ "Chinese mode changes UI chrome but leaves node titles, summaries, bad-case titles, and test-chain text in English.": "中文模式只切换界面文字,但节点标题、摘要、bad case 标题和测试链路仍是英文。",
1946
+ "Lane labels were styled like normal horizontal text after moving them to the left column.": "轨道标签移到左侧列后仍按普通横排文本样式渲染。",
1947
+ "Record fields were emitted as escaped static text rather than language-aware text spans.": "记录字段被输出为转义后的静态文本,而不是支持语言切换的文本片段。",
1948
+ "Render lane labels with vertical writing mode and narrow sticky label cells.": "用竖排书写模式和窄粘性标签格渲染轨道标签。",
1949
+ "Wrap human-facing record strings in `data-i18n-text` spans with English and Chinese variants.": "将面向用户的记录字符串包进带英文和中文版本的 `data-i18n-text` 片段。",
1950
+ }
1951
+
1952
+
1953
+ ZH_REPLACEMENTS: list[tuple[str, str]] = [
1954
+ ("Stop hook", "结束钩子"),
1955
+ ("stop condition", "停止条件"),
1956
+ ("stop hook", "结束钩子"),
1957
+ ("red signal", "红色信号"),
1958
+ ("post-fix", "修复后"),
1959
+ ("Context Evidence and Guards section", "context 证据和守卫规则章节"),
1960
+ ("Chinese mode should show Chinese records", "中文模式应显示中文记录"),
1961
+ ("Chinese UI chrome", "中文 UI 外壳"),
1962
+ ("static interface labels", "静态界面标签"),
1963
+ ("localized record text", "本地化记录文本"),
1964
+ ("single context projection", "同一份 context 投影"),
1965
+ ("lane writing mode", "轨道书写方向"),
1966
+ ("localized overview records", "本地化概览记录"),
1967
+ ("localized detail records", "本地化详情记录"),
1968
+ ("stable roadmap files", "稳定路线图文件"),
1969
+ ("current skill", "当前 skill"),
1970
+ ("GitHub repository", "GitHub 仓库"),
1971
+ ("chat history", "聊天历史"),
1972
+ ("Roadmap overview and details", "路线图概览和详情"),
1973
+ ("Roadmap overview", "路线图概览"),
1974
+ ("Roadmap display", "路线图展示"),
1975
+ ("roadmap display", "路线图展示"),
1976
+ ("roadmap overview", "路线图概览"),
1977
+ ("roadmap", "路线图"),
1978
+ ("record titles", "记录标题"),
1979
+ ("record content", "记录内容"),
1980
+ ("record strings", "记录文本"),
1981
+ ("summaries", "摘要"),
1982
+ ("summary", "摘要"),
1983
+ ("test snippets", "测试片段"),
1984
+ ("test-chain text", "测试链路文本"),
1985
+ ("node titles", "节点标题"),
1986
+ ("Main Route", "主要路线"),
1987
+ ("Bad Cases", "Bad Case"),
1988
+ ("Test Chain", "测试链路"),
1989
+ ("bad-case memory", "bad case 记忆"),
1990
+ ("bad cases", "bad case"),
1991
+ ("bad case", "bad case"),
1992
+ ("goal-mode", "goal 模式"),
1993
+ ("goal mode", "goal 模式"),
1994
+ ("Chinese/English", "中英文"),
1995
+ ("HTML files", "HTML 文件"),
1996
+ ("stable", "稳定"),
1997
+ ("assertion checks", "断言检查"),
1998
+ ("context folder", "context 文件夹"),
1999
+ ("skill instructions", "skill 说明"),
2000
+ ("instructions", "说明"),
2001
+ ("template", "模板"),
2002
+ ("nodes appear", "节点出现"),
2003
+ ("old layout class", "旧布局 class"),
2004
+ ("all three lanes", "三条轨道"),
2005
+ ("Inspect ", "检查 "),
2006
+ ("Read ", "阅读 "),
2007
+ ("Re-run ", "重新运行 "),
2008
+ ("Run ", "运行 "),
2009
+ (" and inspect ", " 并检查 "),
2010
+ (" and confirm ", " 并确认 "),
2011
+ (" and ", " 和 "),
2012
+ ("language toggles", "语言切换"),
2013
+ ("URL parameter support", "URL 参数支持"),
2014
+ ("details page labels", "详情页标签"),
2015
+ ("node cards", "节点卡片"),
2016
+ ("lane labels", "轨道标签"),
2017
+ ("left lane label column", "左侧轨道标签列"),
2018
+ ("left label column", "左侧标签列"),
2019
+ ("vertical", "竖排"),
2020
+ ("horizontally", "横向"),
2021
+ ("horizontal", "横向"),
2022
+ ("narrow screens", "窄屏"),
2023
+ ("writing mode", "书写方向"),
2024
+ ("left-side column", "左侧列"),
2025
+ ("labels", "标签"),
2026
+ ("updated", "更新"),
2027
+ ("support", "支持"),
2028
+ ("supports", "支持"),
2029
+ ("User said ", "用户要求"),
2030
+ ("User asked for ", "用户要求"),
2031
+ ("should be ", "应"),
2032
+ ("not only ", "不只是"),
2033
+ ("Do not treat ", "不要把"),
2034
+ (" as only translating ", "只当成翻译"),
2035
+ ("Keep ", "保持"),
2036
+ (" concise", "精简"),
2037
+ (" and sourced from ", ",并来自"),
2038
+ (" covers ", " 覆盖"),
2039
+ (" so humans can understand", ",让人类可以理解"),
2040
+ (" and install it from ", "并从"),
2041
+ ("Do not leave ", "不要把"),
2042
+ (" only inside ", "只留在"),
2043
+ (" when ", "当"),
2044
+ (" changes", "变化时"),
2045
+ ("purpose", "目标"),
2046
+ ("installation", "安装"),
2047
+ ("setup", "设置"),
2048
+ ("usage", "用法"),
2049
+ ("layout", "布局"),
2050
+ ("rules", "规则"),
2051
+ ("model", "模型"),
2052
+ ("verification", "验证"),
2053
+ ]
2054
+
2055
+
2056
+ DETAIL_ZH_REWRITES: list[tuple[str, str]] = [
2057
+ (
2058
+ "用户反馈 skill 又陷入测试循环,说明验证预算还不够,需要明确停止条件",
2059
+ "用户发现 Context Guard 又把时间耗在反复补测试上,因此需要给验证流程设置明确的停止条件",
2060
+ ),
2061
+ (
2062
+ "Context Guard 现在承认用户截图、日志、复现和已定位根因可作为红色信号",
2063
+ "现在只要有截图、日志、复现步骤或明确根因,就可以确认问题已经成立",
2064
+ ),
2065
+ (
2066
+ "证据足够时应停止补测试并进入实现",
2067
+ "证据足够时,先修复问题,再做最小验证",
2068
+ ),
2069
+ (
2070
+ "后续观察 结束钩子 是否能让 Codex 在根因明确后先修复,再做一个最小 修复后 检查",
2071
+ "下一步观察结束钩子能否提醒 Codex:根因明确后先修复,再做一次最小检查",
2072
+ ),
2073
+ (
2074
+ "后续观察结束钩子是否能让 Codex 在根因明确后先修复,再做一个最小修复后检查",
2075
+ "下一步观察结束钩子能否提醒 Codex:根因明确后先修复,再做一次最小检查",
2076
+ ),
2077
+ (
2078
+ "不要把 TDD 惯性套到每个 bugfix",
2079
+ "不要每个修复都强行套用先写红测的流程",
2080
+ ),
2081
+ (
2082
+ "可信证据存在时不要继续制造红测",
2083
+ "已有可靠证据时,不再继续补红测",
2084
+ ),
2085
+ (
2086
+ "可信证据存在时仍制造红测导致测试循环",
2087
+ "已有可信证据时仍继续补红测,导致陷入测试循环",
2088
+ ),
2089
+ ]
2090
+
2091
+
2092
+ def has_cjk(text: str) -> bool:
2093
+ return bool(re.search(r"[\u3400-\u9fff]", text))
2094
+
2095
+
2096
+ def apply_zh_replacements(text: str) -> str:
2097
+ parts = re.split(r"(`[^`]*`)", text)
2098
+ translated_parts: list[str] = []
2099
+ for part in parts:
2100
+ if part.startswith("`") and part.endswith("`"):
2101
+ translated_parts.append(part)
2102
+ continue
2103
+ translated = part
2104
+ for source, target in ZH_REPLACEMENTS:
2105
+ translated = translated.replace(source, target)
2106
+ translated_parts.append(translated)
2107
+ return "".join(translated_parts)
2108
+
2109
+
2110
+ def clean_zh_term_spacing(text: str) -> str:
2111
+ text = re.sub(r"([\u3400-\u9fff])\s+(停止条件|结束钩子|红色信号|修复后)", r"\1\2", text)
2112
+ text = re.sub(r"(停止条件|结束钩子|红色信号|修复后)\s+([\u3400-\u9fff])", r"\1\2", text)
2113
+ return text
2114
+
2115
+
2116
+ def zh_text(text: str) -> str:
2117
+ text = human_text(text)
2118
+ normalized = " ".join(text.split())
2119
+ if normalized in ZH_TEXT:
2120
+ return ZH_TEXT[normalized]
2121
+ return clean_zh_term_spacing(apply_zh_replacements(normalized))
2122
+
2123
+
2124
+ def polish_detail_zh(text: str) -> str:
2125
+ polished = zh_text(text)
2126
+ for source, target in DETAIL_ZH_REWRITES:
2127
+ polished = polished.replace(source, target)
2128
+ polished = re.sub(r"\s+", " ", polished).strip()
2129
+ polished = polished.replace(" ,", ",").replace(" 。", "。").replace(" :", ":")
2130
+ return polished
2131
+
2132
+
2133
+ def localized_text(text: str) -> str:
2134
+ en = human_text(text)
2135
+ zh = zh_text(en)
2136
+ return (
2137
+ f'<span data-i18n-text data-en="{html.escape(en, quote=True)}" '
2138
+ f'data-zh="{html.escape(zh, quote=True)}">{html.escape(en)}</span>'
2139
+ )
2140
+
2141
+
2142
+ def localized_short_text(text: str, limit: int = 92) -> str:
2143
+ en = short_text(text, limit)
2144
+ zh = short_text(zh_text(text), limit)
2145
+ return (
2146
+ f'<span data-i18n-text data-en="{html.escape(en, quote=True)}" '
2147
+ f'data-zh="{html.escape(zh, quote=True)}">{html.escape(en)}</span>'
2148
+ )
2149
+
2150
+
2151
+ def localized_detail_text(text: str, limit: int = 132) -> str:
2152
+ en = short_text(text, limit)
2153
+ zh = short_text(polish_detail_zh(text), limit)
2154
+ return (
2155
+ f'<span data-i18n-text data-en="{html.escape(en, quote=True)}" '
2156
+ f'data-zh="{html.escape(zh, quote=True)}">{html.escape(en)}</span>'
2157
+ )
2158
+
2159
+
2160
+ def short_text(text: str, limit: int = 92) -> str:
2161
+ text = " ".join(human_text(text).split())
2162
+ if len(text) <= limit:
2163
+ return text
2164
+ return text[: limit - 1].rstrip() + "..."
2165
+
2166
+
2167
+ def visual_status_class(status: str) -> str:
2168
+ normalized = status.strip().lower()
2169
+ if normalized in {"done", "resolved", "passed", "complete", "completed"}:
2170
+ return "status-ok"
2171
+ if normalized in {"active", "current", "planned", "open"}:
2172
+ return "status-active"
2173
+ if normalized in {"deferred", "superseded", "superseded-by-route-change"}:
2174
+ return "status-muted"
2175
+ if normalized in {"recurred", "failed", "failing", "blocked"}:
2176
+ return "status-bad"
2177
+ if normalized in {"warning", "warn", "at-risk"}:
2178
+ return "status-warn"
2179
+ return "status-muted"
2180
+
2181
+
2182
+ def status_dot(status: str) -> str:
2183
+ return f'<span class="status-dot {visual_status_class(status)}" aria-hidden="true"></span>'
2184
+
2185
+
2186
+ def frequency_dot(frequency: str) -> str:
2187
+ normalized = frequency.strip().lower()
2188
+ if normalized.startswith("repeated") or normalized in {"high-frequency", "hot"}:
2189
+ return '<span class="freq-dot" aria-hidden="true"></span>'
2190
+ return ""
2191
+
2192
+
2193
+ def parse_tags(text: str) -> list[str]:
2194
+ tags = re.findall(r"#[A-Za-z0-9_-]+", text or "")
2195
+ seen: set[str] = set()
2196
+ unique: list[str] = []
2197
+ for tag in tags:
2198
+ key = tag.lower()
2199
+ if key not in seen:
2200
+ seen.add(key)
2201
+ unique.append(tag)
2202
+ return unique
2203
+
2204
+
2205
+ def tag_class(tag: str) -> str:
2206
+ normalized = tag.lower()
2207
+ if any(key in normalized for key in ["ux", "ui", "label", "roadmap", "display"]):
2208
+ return "tag-blue"
2209
+ if any(key in normalized for key in ["risk", "hot", "flaky", "trigger", "missed"]):
2210
+ return "tag-amber"
2211
+ if any(key in normalized for key in ["resolved", "guard", "context", "source"]):
2212
+ return "tag-green"
2213
+ if any(key in normalized for key in ["loss", "bloat", "noise", "data"]):
2214
+ return "tag-rose"
2215
+ return "tag-slate"
2216
+
2217
+
2218
+ def tag_emoji(tag: str) -> str:
2219
+ normalized = tag.lower()
2220
+ if any(key in normalized for key in ["risk", "hot", "flaky", "trigger", "missed"]):
2221
+ return "⚠️"
2222
+ if any(key in normalized for key in ["roadmap", "route", "layout"]):
2223
+ return "🧭"
2224
+ if any(key in normalized for key in ["ux", "ui", "display", "label"]):
2225
+ return "✨"
2226
+ if any(key in normalized for key in ["context", "source", "folder"]):
2227
+ return "🧠"
2228
+ if any(key in normalized for key in ["loss", "bloat", "noise", "data", "storage"]):
2229
+ return "🧹"
2230
+ if any(key in normalized for key in ["guard", "resolved", "test"]):
2231
+ return "✅"
2232
+ if "tag" in normalized:
2233
+ return "🏷️"
2234
+ return "🏷️"
2235
+
2236
+
2237
+ TAG_LABELS_ZH: dict[str, str] = {
2238
+ "bad-case-lag": "bad case 延迟",
2239
+ "bad-case-link": "bad case 链接",
2240
+ "branch-support": "分支支持",
2241
+ "context-bloat": "context 膨胀",
2242
+ "context-loss": "context 丢失",
2243
+ "context-source": "context 源",
2244
+ "data-loss": "数据丢失",
2245
+ "display": "展示",
2246
+ "folder-scope": "文件夹范围",
2247
+ "goal-mode": "目标模式",
2248
+ "hot": "高频",
2249
+ "i18n": "多语言",
2250
+ "install-sync": "安装同步",
2251
+ "language-pref": "语言偏好",
2252
+ "layout-model": "布局模型",
2253
+ "label-noise": "标签噪声",
2254
+ "numbering": "编号",
2255
+ "overview-clutter": "概览拥挤",
2256
+ "parser": "解析",
2257
+ "process-drift": "流程漂移",
2258
+ "projection-integrity": "投影完整性",
2259
+ "roadmap-node": "路线节点",
2260
+ "roadmap-ux": "路线图体验",
2261
+ "route-risk": "路线风险",
2262
+ "skill-packaging": "skill 打包",
2263
+ "skill-trigger-risk": "skill 触发风险",
2264
+ "tag-support": "标签支持",
2265
+ "typography": "字体",
2266
+ "visual-design": "视觉设计",
2267
+ }
2268
+
2269
+
2270
+ TAG_PARTS_ZH: dict[str, str] = {
2271
+ "bad": "bad",
2272
+ "case": "case",
2273
+ "context": "context",
2274
+ "display": "展示",
2275
+ "flaky": "不稳定",
2276
+ "folder": "文件夹",
2277
+ "goal": "目标",
2278
+ "guard": "防线",
2279
+ "hot": "高频",
2280
+ "i18n": "多语言",
2281
+ "label": "标签",
2282
+ "lag": "延迟",
2283
+ "language": "语言",
2284
+ "layout": "布局",
2285
+ "link": "链接",
2286
+ "loss": "丢失",
2287
+ "mode": "模式",
2288
+ "node": "节点",
2289
+ "noise": "噪声",
2290
+ "overview": "概览",
2291
+ "packaging": "打包",
2292
+ "parser": "解析",
2293
+ "pref": "偏好",
2294
+ "projection": "投影",
2295
+ "roadmap": "路线图",
2296
+ "route": "路线",
2297
+ "risk": "风险",
2298
+ "source": "源",
2299
+ "support": "支持",
2300
+ "sync": "同步",
2301
+ "tag": "标签",
2302
+ "test": "测试",
2303
+ "trigger": "触发",
2304
+ "ux": "体验",
2305
+ }
2306
+
2307
+
2308
+ def tag_slug(tag: str) -> str:
2309
+ return tag.strip().lstrip("#").lower()
2310
+
2311
+
2312
+ def tag_label_en(tag: str) -> str:
2313
+ return tag_slug(tag).replace("_", "-").replace("-", " ")
2314
+
2315
+
2316
+ def tag_label_zh(tag: str) -> str:
2317
+ slug = tag_slug(tag)
2318
+ if slug in TAG_LABELS_ZH:
2319
+ return TAG_LABELS_ZH[slug]
2320
+ parts = [TAG_PARTS_ZH.get(part, part) for part in re.split(r"[-_]+", slug) if part]
2321
+ return " ".join(parts) if parts else tag_label_en(tag)
2322
+
2323
+
2324
+ def localized_tag_label(tag: str) -> str:
2325
+ en = tag_label_en(tag)
2326
+ zh = tag_label_zh(tag)
2327
+ return (
2328
+ f'<span data-i18n-text data-en="{html.escape(en, quote=True)}" '
2329
+ f'data-zh="{html.escape(zh, quote=True)}">{html.escape(en)}</span>'
2330
+ )
2331
+
2332
+
2333
+ def render_tags(tags: list[str], limit: int | None = None) -> str:
2334
+ if limit is not None:
2335
+ visible = tags[:limit]
2336
+ hidden = len(tags) - len(visible)
2337
+ else:
2338
+ visible = tags
2339
+ hidden = 0
2340
+ pieces = [
2341
+ f'<span class="tag {tag_class(tag)}"><span class="tag-emoji" aria-hidden="true">{html.escape(tag_emoji(tag))}</span>{localized_tag_label(tag)}</span>'
2342
+ for tag in visible
2343
+ ]
2344
+ if hidden > 0:
2345
+ pieces.append(f'<span class="tag tag-more">+{hidden}</span>')
2346
+ return "".join(pieces)
2347
+
2348
+
2349
+ def build_case_anchor_map(cards: list[dict[str, str]]) -> dict[str, str]:
2350
+ return {card.get("title", ""): f"case-{i}" for i, card in enumerate(cards, 1)}
2351
+
2352
+
2353
+ def node_ids_from_text(text: str) -> list[str]:
2354
+ return re.findall(r"NODE-\d{8}-\d+", text or "")
2355
+
2356
+
2357
+ def bad_case_id(card: dict[str, str]) -> str:
2358
+ title = card.get("title", "")
2359
+ match = re.match(r"(BC-\d{8}-\d+)", title)
2360
+ return match.group(1) if match else title.split(":", 1)[0].strip()
2361
+
2362
+
2363
+ def normalized_parent_id(value: str) -> str:
2364
+ value = strip_wrapping_backticks((value or "").strip())
2365
+ if not value or value.lower() in {"none", "n/a", "null"}:
2366
+ return ""
2367
+ match = re.search(r"NODE-\d{8}-\d+", value)
2368
+ return match.group(0) if match else value
2369
+
2370
+
2371
+ def linked_bad_case_ids_for_node(node: dict[str, str], cards: list[dict[str, str]]) -> list[str]:
2372
+ direct = re.findall(r"BC-\d{8}-\d+", node.get("linked bad cases", ""))
2373
+ linked: list[str] = []
2374
+ for cid in direct:
2375
+ if cid not in linked:
2376
+ linked.append(cid)
2377
+ nid = node_id(node)
2378
+ for card in cards:
2379
+ cid = bad_case_id(card)
2380
+ if cid and nid and nid in card.get("roadmap nodes", "") and cid not in linked:
2381
+ linked.append(cid)
2382
+ return linked
2383
+
2384
+
2385
+ def bad_cases_for_node(node: dict[str, str], cards: list[dict[str, str]]) -> list[dict[str, str]]:
2386
+ nid = node_id(node)
2387
+ linked = set(re.findall(r"BC-\d{8}-\d+", node.get("linked bad cases", "")))
2388
+ matched: list[dict[str, str]] = []
2389
+ for card in cards:
2390
+ card_nodes = card.get("roadmap nodes", "")
2391
+ card_id = card.get("title", "").split(":", 1)[0].strip()
2392
+ if nid and nid in card_nodes:
2393
+ matched.append(card)
2394
+ elif card_id in linked:
2395
+ matched.append(card)
2396
+ return matched
2397
+
2398
+
2399
+ def render_route_filter(route_groups: list[tuple[str, list[tuple[int, dict[str, str]]]]]) -> str:
2400
+ buttons = []
2401
+ for i, (branch, items) in enumerate(route_groups):
2402
+ major_count = len([node for _, node in items if node_level(node) == "major"]) or len(items)
2403
+ slug = html.escape(route_slug(branch))
2404
+ pressed = "true" if i == 0 else "false"
2405
+ buttons.append(
2406
+ f'<button type="button" data-route-filter="{slug}" aria-pressed="{pressed}">'
2407
+ f'{localized_text(branch)} <span aria-hidden="true">{major_count}</span></button>'
2408
+ )
2409
+ return (
2410
+ '<div class="route-filter" aria-label="Routes">'
2411
+ '<span class="route-filter-label" data-i18n="routeFocus">Route Focus</span>'
2412
+ + "".join(buttons)
2413
+ + "</div>"
2414
+ )
2415
+
2416
+
2417
+ def render_route_group(
2418
+ branch: str,
2419
+ items: list[tuple[int, dict[str, str]]],
2420
+ bad_case_cards: list[dict[str, str]],
2421
+ case_anchor_map: dict[str, str],
2422
+ branch_mode: bool = False,
2423
+ node_lookup: dict[str, dict[str, str]] | None = None,
2424
+ route_offset: int = 0,
2425
+ route_depth: int = 0,
2426
+ parent_node_id: str = "",
2427
+ parent_anchor_id: str = "",
2428
+ ) -> str:
2429
+ major_items = [(number, node) for number, node in items if node_level(node) == "major"]
2430
+ hidden_count = len(items) - len(major_items)
2431
+ display_items = display_items_for_route(items)
2432
+ route_offset = max(0, route_offset)
2433
+ route_spacers = render_route_spacers(route_offset) if branch_mode else ""
2434
+ if branch_mode:
2435
+ show_route_tests = route_has_approved_tests(display_items, bad_case_cards)
2436
+ columns = "\n".join(
2437
+ render_route_column(
2438
+ node,
2439
+ source_number,
2440
+ display_number,
2441
+ bad_case_cards,
2442
+ case_anchor_map,
2443
+ branch_start=branch_mode and display_number == 1 and route_offset > 0,
2444
+ show_test_line=show_route_tests,
2445
+ )
2446
+ for display_number, (source_number, node) in enumerate(display_items, 1)
2447
+ )
2448
+ else:
2449
+ columns = "\n".join(
2450
+ render_route_column(
2451
+ node,
2452
+ source_number,
2453
+ display_number,
2454
+ branch_start=False,
2455
+ show_test_line=False,
2456
+ )
2457
+ for display_number, (source_number, node) in enumerate(display_items, 1)
2458
+ )
2459
+ label = localized_text(branch)
2460
+ count = len(major_items) if major_items else len(items)
2461
+ parent_note = render_route_parent_note(branch, items, node_lookup or {}) if branch_mode else ""
2462
+ checkpoint_strip = ""
2463
+ if hidden_count > 0:
2464
+ checkpoint_strip = (
2465
+ f'<div class="checkpoint-strip"><span class="checkpoint-dot" aria-hidden="true"></span>'
2466
+ f'<span data-i18n="checkpointsInDetails" data-count="{hidden_count}">{hidden_count} checkpoints in details</span></div>'
2467
+ )
2468
+ label_column = """<section class="track-label-column" aria-hidden="true">
2469
+ <div class="track-label-cell"><span class="lane-label" data-i18n="mainRoute">Main Route</span></div>
2470
+ <div class="track-label-cell"><span class="lane-label" data-i18n="badCases">Bad Cases</span></div>
2471
+ <div class="track-label-cell"><span class="lane-label" data-i18n="testChain">Test Chain</span></div>
2472
+ </section>"""
2473
+ label_column = ""
2474
+ grid_class = "track-grid route-only" if branch_mode else "track-grid route-only single-mainline"
2475
+ branch_class = " route-branch" if parent_note else ""
2476
+ route_head = f"""<div class="route-head">
2477
+ <span class="route-mark" aria-hidden="true"></span>
2478
+ <span class="route-title">{label}</span>
2479
+ <span class="route-pill">{count}</span>
2480
+ {parent_note}
2481
+ </div>"""
2482
+ if branch_mode:
2483
+ head_start_class = " branch-start" if route_offset > 0 and parent_note else ""
2484
+ route_anchor_attr = " data-route-anchor" if head_start_class else ""
2485
+ route_header = f"""<div class="route-head-grid {grid_class}">{route_spacers}<div class="route-head-cell{head_start_class}"{route_anchor_attr}>
2486
+ {route_head}
2487
+ {checkpoint_strip}
2488
+ </div></div>"""
2489
+ else:
2490
+ route_header = f"""{route_head}
2491
+ {checkpoint_strip}"""
2492
+ route_vars = route_color_vars(route_depth)
2493
+ branch_drift = 0
2494
+ if branch_mode and parent_note:
2495
+ branch_drift = 44 + max(route_depth, 1) * 10
2496
+ route_vars = f"{route_vars} --branch-drift: {branch_drift}px;"
2497
+ offset_attrs = (
2498
+ f' data-route-offset="{route_offset}" data-route-depth="{route_depth}" style="{route_vars}"'
2499
+ if branch_mode
2500
+ else f' data-route-depth="{route_depth}" style="{route_vars}"'
2501
+ )
2502
+ if branch_mode and parent_note and parent_node_id and parent_anchor_id:
2503
+ offset_attrs = (
2504
+ f' data-route-offset="{route_offset}" data-route-depth="{route_depth}"'
2505
+ f' data-branch-drift="{branch_drift}"'
2506
+ f' data-parent-node-id="{html.escape(parent_node_id)}"'
2507
+ f' data-parent-anchor-id="{html.escape(parent_anchor_id)}"'
2508
+ f' style="{route_vars}"'
2509
+ )
2510
+ return f"""<section class="route-group{branch_class}" data-route-group="{html.escape(route_slug(branch))}"{offset_attrs}>
2511
+ {route_header}
2512
+ <div class="route-strip">
2513
+ <div class="{grid_class}">{label_column}{route_spacers}{columns}</div>
2514
+ </div>
2515
+ </section>"""
2516
+
2517
+
2518
+ def render_route_spacers(count: int) -> str:
2519
+ if count <= 0:
2520
+ return ""
2521
+ return "".join('<div class="route-spacer" aria-hidden="true"></div>' for _ in range(count))
2522
+
2523
+
2524
+ def render_route_parent_note(
2525
+ branch: str,
2526
+ items: list[tuple[int, dict[str, str]]],
2527
+ node_lookup: dict[str, dict[str, str]],
2528
+ ) -> str:
2529
+ parent_id = external_parent_id(branch, items, node_lookup)
2530
+ if not parent_id:
2531
+ return ""
2532
+ parent_node = node_lookup.get(parent_id, {})
2533
+ parent_label = node_display_title(parent_node, parent_id)
2534
+ return f'<span class="route-parent" data-route-parent>{localized_text("forked from")} {localized_text(parent_label)}</span>'
2535
+
2536
+
2537
+ def render_route_column(
2538
+ node: dict[str, str],
2539
+ source_number: int,
2540
+ display_number: int,
2541
+ bad_case_cards: list[dict[str, str]] | None = None,
2542
+ case_anchor_map: dict[str, str] | None = None,
2543
+ branch_start: bool = False,
2544
+ show_test_line: bool = True,
2545
+ ) -> str:
2546
+ title = localized_text(node_display_title(node, f"Node {source_number}"))
2547
+ status = node.get("status", "unknown")
2548
+ date = html.escape(node.get("date", "undated"))
2549
+ outcome = localized_short_text(node.get("outcome", "No outcome recorded."))
2550
+ branch_class = " branch-start" if branch_start else ""
2551
+ overview_id = html.escape(node_id(node))
2552
+ test_line = ""
2553
+ if show_test_line:
2554
+ cases = approved_test_cases_for_node(node, bad_case_cards or [])
2555
+ visible_cases = cases[:2]
2556
+ hidden_test_count = max(0, len(cases) - len(visible_cases))
2557
+ test_items = "\n".join(
2558
+ render_route_test_note(card, (case_anchor_map or {}).get(card.get("title", ""), "case-1"))
2559
+ for card in visible_cases
2560
+ )
2561
+ if hidden_test_count:
2562
+ test_items += f'\n<span class="route-test-more">+{hidden_test_count}</span>'
2563
+ test_line = (
2564
+ f"""<article class="route-test-line has-tests" data-lane="test-chain">
2565
+ {test_items}
2566
+ </article>"""
2567
+ if test_items
2568
+ else '<div class="route-test-line route-test-empty" data-lane="test-chain" aria-hidden="true"></div>'
2569
+ )
2570
+ mainline_only_class = " no-test-line" if not show_test_line else ""
2571
+ return f"""<section class="track-column route-column{branch_class}{mainline_only_class}" data-overview-node-id="{overview_id}">
2572
+ <article class="lane lane-main" data-lane="main">
2573
+ <a class="lane-link" href="#node-{source_number}">
2574
+ <div class="node-heading">
2575
+ <div class="node-number">{display_number}</div>
2576
+ <h3>{title}</h3>
2577
+ </div>
2578
+ <div class="node-meta">
2579
+ {status_dot(status)}
2580
+ <span class="pill">{date}</span>
2581
+ </div>
2582
+ <p class="summary">{outcome}</p>
2583
+ </a>
2584
+ </article>
2585
+ {test_line}
2586
+ </section>"""
2587
+
2588
+
2589
+ def route_has_approved_tests(
2590
+ items: list[tuple[int, dict[str, str]]],
2591
+ bad_case_cards: list[dict[str, str]],
2592
+ ) -> bool:
2593
+ return any(approved_test_cases_for_node(node, bad_case_cards) for _, node in items)
2594
+
2595
+
2596
+ def approved_test_cases_for_node(
2597
+ node: dict[str, str],
2598
+ bad_case_cards: list[dict[str, str]],
2599
+ ) -> list[dict[str, str]]:
2600
+ return [card for card in bad_cases_for_node(node, bad_case_cards) if has_approved_test_policy(card)]
2601
+
2602
+
2603
+ def has_approved_test_policy(card: dict[str, str]) -> bool:
2604
+ policy = strip_wrapping_backticks(card.get("run policy", "")).strip().lower()
2605
+ if not policy:
2606
+ return False
2607
+ if policy in {"proposed", "pending", "disabled", "disabled-with-reason", "none", "n/a", "null"}:
2608
+ return False
2609
+ return True
2610
+
2611
+
2612
+ def render_track_column(
2613
+ node: dict[str, str],
2614
+ source_number: int,
2615
+ display_number: int,
2616
+ bad_case_cards: list[dict[str, str]],
2617
+ case_anchor_map: dict[str, str],
2618
+ ) -> str:
2619
+ title = localized_text(node_display_title(node, f"Node {source_number}"))
2620
+ status = node.get("status", "unknown")
2621
+ date = html.escape(node.get("date", "undated"))
2622
+ outcome = localized_short_text(node.get("outcome", "No outcome recorded."))
2623
+ cases = bad_cases_for_node(node, bad_case_cards)
2624
+ case_items = "\n".join(render_bad_case_summary(card, case_anchor_map.get(card.get("title", ""), "case-1")) for card in cases)
2625
+ case_lane = (
2626
+ f"""<article class="lane lane-bad-cases" data-lane="bad-cases">
2627
+ {case_items}
2628
+ </article>"""
2629
+ if case_items
2630
+ else '<article class="lane lane-empty" data-lane="bad-cases" aria-hidden="true"></article>'
2631
+ )
2632
+ test_items = "\n".join(
2633
+ render_bad_case_test_note(card, case_anchor_map.get(card.get("title", ""), "case-1"))
2634
+ for card in cases
2635
+ )
2636
+ test_lane = (
2637
+ f"""<article class="lane lane-test-chain" data-lane="test-chain">
2638
+ {test_items}
2639
+ </article>"""
2640
+ if test_items
2641
+ else '<article class="lane lane-empty" data-lane="test-chain" aria-hidden="true"></article>'
2642
+ )
2643
+ return f"""<section class="track-column">
2644
+ <article class="lane lane-main" data-lane="main">
2645
+ <a class="lane-link" href="#node-{source_number}">
2646
+ <div class="node-heading">
2647
+ <div class="node-number">{display_number}</div>
2648
+ <h3>{title}</h3>
2649
+ </div>
2650
+ <div class="node-meta">
2651
+ {status_dot(status)}
2652
+ <span class="pill">{date}</span>
2653
+ </div>
2654
+ <p class="summary">{outcome}</p>
2655
+ </a>
2656
+ </article>
2657
+ {case_lane}
2658
+ {test_lane}
2659
+ </section>"""
2660
+
2661
+
2662
+ def render_node_detail(
2663
+ node: dict[str, str],
2664
+ number: int,
2665
+ bad_case_cards: list[dict[str, str]],
2666
+ case_anchor_map: dict[str, str],
2667
+ ) -> str:
2668
+ title = localized_text(node_display_title(node, f"Node {number}"))
2669
+ cases = bad_cases_for_node(node, bad_case_cards)
2670
+ user_problem = node.get("user request", "").strip()
2671
+ if not user_problem:
2672
+ user_problem = "No source user request recorded for this historical node."
2673
+ method_items = node_method_items(node, cases)
2674
+ progress_items = node_progress_items(node)
2675
+ case_html = render_node_case_list(cases, case_anchor_map)
2676
+ return f"""<section class="detail-card" id="node-{number}">
2677
+ <h3>{number}. {title}</h3>
2678
+ <div class="detail-grid">
2679
+ {render_node_detail_section("User question", "用户提出的问题", localized_detail_text(user_problem or "No question recorded.", 240))}
2680
+ {render_node_detail_section("Current progress", "当前进度", render_detail_item_list(progress_items, "No progress recorded.", "没有记录当前进度。"))}
2681
+ {render_node_detail_section("Bad cases to solve", "相关问题案例", case_html, wide=True)}
2682
+ {render_node_detail_section("Method", "采取方法", render_detail_item_list(method_items, "No method recorded.", "没有记录采取方法。"), wide=True)}
2683
+ </div>
2684
+ </section>"""
2685
+
2686
+
2687
+ def localized_label(en: str, zh: str) -> str:
2688
+ return (
2689
+ f'<span data-i18n-text data-en="{html.escape(en, quote=True)}" '
2690
+ f'data-zh="{html.escape(zh, quote=True)}">{html.escape(en)}</span>'
2691
+ )
2692
+
2693
+
2694
+ def render_node_detail_section(label_en: str, label_zh: str, body: str, wide: bool = False) -> str:
2695
+ wide_class = " wide" if wide else ""
2696
+ return f"""<section class="detail-section{wide_class}">
2697
+ <h4>{localized_label(label_en, label_zh)}</h4>
2698
+ <div>{body}</div>
2699
+ </section>"""
2700
+
2701
+
2702
+ def render_detail_item_list(items: list[str], empty_en: str, empty_zh: str, limit: int = 132) -> str:
2703
+ cleaned = [item for item in items if meaningful_bad_case_value(item)]
2704
+ if not cleaned:
2705
+ return f'<p class="muted">{localized_label(empty_en, empty_zh)}</p>'
2706
+ lines = "\n".join(f"<li>{localized_detail_text(item, limit)}</li>" for item in cleaned[:4])
2707
+ return f'<ul class="detail-list">\n{lines}\n</ul>'
2708
+
2709
+
2710
+ def split_detail_items(text: str) -> list[str]:
2711
+ raw = human_text(text)
2712
+ parts: list[str] = []
2713
+ buf: list[str] = []
2714
+ in_code = False
2715
+ for char in raw:
2716
+ if char == "`":
2717
+ in_code = not in_code
2718
+ buf.append(char)
2719
+ continue
2720
+ if not in_code and char in {";", ";", "。"}:
2721
+ part = "".join(buf).strip(" ::,, ")
2722
+ if meaningful_bad_case_value(part):
2723
+ parts.append(part)
2724
+ buf = []
2725
+ continue
2726
+ buf.append(char)
2727
+ tail = "".join(buf).strip(" ::,, ")
2728
+ if meaningful_bad_case_value(tail):
2729
+ parts.append(tail)
2730
+ return [part.strip(" ::,, ") for part in parts if meaningful_bad_case_value(part.strip())]
2731
+
2732
+
2733
+ def node_method_items(node: dict[str, str], cases: list[dict[str, str]]) -> list[str]:
2734
+ preferred = node.get("method summary", "").strip()
2735
+ if preferred:
2736
+ return split_detail_items(preferred)[:4]
2737
+ methods: list[str] = []
2738
+ for card in cases:
2739
+ fix = first_nonempty(card.get("fix method", ""), card.get("guard / verification", ""))
2740
+ for item in split_detail_items(fix):
2741
+ if item not in methods:
2742
+ methods.append(item)
2743
+ for value in [node.get("avoid going back", ""), node.get("decision / reason", "")]:
2744
+ for item in split_detail_items(value):
2745
+ if item not in methods:
2746
+ methods.append(item)
2747
+ return methods[:4]
2748
+
2749
+
2750
+ def node_progress_items(node: dict[str, str]) -> list[str]:
2751
+ preferred = node.get("progress summary", "").strip()
2752
+ if preferred:
2753
+ return split_detail_items(preferred)[:4]
2754
+ parts = []
2755
+ for value in [node.get("outcome", ""), node.get("next", "")]:
2756
+ for item in split_detail_items(value):
2757
+ if item not in parts:
2758
+ parts.append(item)
2759
+ return parts[:4]
2760
+
2761
+
2762
+ def render_node_case_list(cases: list[dict[str, str]], case_anchor_map: dict[str, str]) -> str:
2763
+ if not cases:
2764
+ return f'<p class="muted">{localized_label("No linked bad cases.", "没有关联问题案例。")}</p>'
2765
+ items = []
2766
+ for card in cases:
2767
+ title = localized_text(human_title(card.get("title", "Bad case")))
2768
+ summary_raw = first_nonempty(
2769
+ card.get("display summary", ""),
2770
+ card.get("phenomenon", ""),
2771
+ card.get("root cause", ""),
2772
+ card.get("fix method", ""),
2773
+ )
2774
+ summary = localized_short_text(summary_raw or "No summary recorded.", 180)
2775
+ tags = render_tags(parse_tags(card.get("tags", "")), limit=3)
2776
+ items.append(
2777
+ f"""<article class="node-case">
2778
+ <p class="node-case-title">{title}</p>
2779
+ <p>{summary}</p>
2780
+ {f'<div class="tags">{tags}</div>' if tags else ''}
2781
+ </article>"""
2782
+ )
2783
+ return "\n".join(items)
2784
+
2785
+
2786
+ def parse_bad_case_cards(text: str) -> list[dict[str, str]]:
2787
+ cards: list[dict[str, str]] = []
2788
+ current: dict[str, str] | None = None
2789
+ for line in text.splitlines():
2790
+ if line.startswith("### "):
2791
+ if current:
2792
+ cards.append(current)
2793
+ current = {"title": line[4:].strip()}
2794
+ continue
2795
+ if current is None:
2796
+ continue
2797
+ stripped = line.strip()
2798
+ if stripped.startswith("- ") and ":" in stripped:
2799
+ key, value = stripped[2:].split(":", 1)
2800
+ normalized_key = canonical_bad_case_key(key)
2801
+ if normalized_key in {"id", "title"}:
2802
+ continue
2803
+ current[normalized_key] = value.strip()
2804
+ if current:
2805
+ cards.append(current)
2806
+ if cards:
2807
+ return cards
2808
+ return parse_loose_bad_case_cards(text)
2809
+
2810
+
2811
+ def canonical_bad_case_key(key: str) -> str:
2812
+ normalized = key.strip().lower().replace("_", " ")
2813
+ aliases = {
2814
+ "id": "id",
2815
+ "case id": "id",
2816
+ "bad case id": "id",
2817
+ "bc": "id",
2818
+ "title": "title",
2819
+ "name": "title",
2820
+ "status": "status",
2821
+ "first observed": "first observed",
2822
+ "last checked": "last checked",
2823
+ "scope": "scope",
2824
+ "context task": "context task",
2825
+ "task": "context task",
2826
+ "roadmap nodes": "roadmap nodes",
2827
+ "nodes": "roadmap nodes",
2828
+ "linked nodes": "roadmap nodes",
2829
+ "node": "roadmap nodes",
2830
+ "tags": "tags",
2831
+ "frequency": "frequency",
2832
+ "display summary": "display summary",
2833
+ "human summary": "display summary",
2834
+ "case summary": "display summary",
2835
+ "summary": "display summary",
2836
+ "phenomenon": "phenomenon",
2837
+ "trigger": "trigger / reproduction",
2838
+ "reproduction": "trigger / reproduction",
2839
+ "trigger / reproduction": "trigger / reproduction",
2840
+ "root cause": "root cause",
2841
+ "cause": "root cause",
2842
+ "fix": "fix method",
2843
+ "fix method": "fix method",
2844
+ "guard type": "guard type",
2845
+ "guard kind": "guard type",
2846
+ "guard category": "guard type",
2847
+ "guard": "guard / verification",
2848
+ "guard / verification": "guard / verification",
2849
+ "verification": "guard / verification",
2850
+ "red condition": "red condition",
2851
+ "red signal": "red condition",
2852
+ "red-capable signal": "red condition",
2853
+ "green condition": "green condition",
2854
+ "green signal": "green condition",
2855
+ "expected failure reason": "expected failure reason",
2856
+ "failure reason": "expected failure reason",
2857
+ "expected red reason": "expected failure reason",
2858
+ "reusable guard path": "reusable guard path",
2859
+ "guard reuse rule": "guard reuse rule",
2860
+ "test chain": "test chain",
2861
+ "tests": "test chain",
2862
+ "high-frequency note": "high-frequency note",
2863
+ "recurrence analysis": "recurrence analysis",
2864
+ "evidence": "evidence",
2865
+ }
2866
+ return aliases.get(normalized, normalized)
2867
+
2868
+
2869
+ def split_loose_bad_case_field(body: str) -> tuple[str, str] | None:
2870
+ if ":" not in body:
2871
+ return None
2872
+ key, value = body.split(":", 1)
2873
+ key = canonical_bad_case_key(key)
2874
+ if key not in {
2875
+ "id",
2876
+ "title",
2877
+ "status",
2878
+ "first observed",
2879
+ "last checked",
2880
+ "scope",
2881
+ "context task",
2882
+ "roadmap nodes",
2883
+ "tags",
2884
+ "frequency",
2885
+ "display summary",
2886
+ "phenomenon",
2887
+ "trigger / reproduction",
2888
+ "root cause",
2889
+ "fix method",
2890
+ "guard type",
2891
+ "guard / verification",
2892
+ "red condition",
2893
+ "green condition",
2894
+ "expected failure reason",
2895
+ "reusable guard path",
2896
+ "guard reuse rule",
2897
+ "test chain",
2898
+ "high-frequency note",
2899
+ "recurrence analysis",
2900
+ "evidence",
2901
+ }:
2902
+ return None
2903
+ return key, value.strip()
2904
+
2905
+
2906
+ def loose_bad_case_title(card: dict[str, str]) -> str:
2907
+ title = card.get("title", "").strip()
2908
+ identifier = card.pop("id", "").strip()
2909
+ if not title:
2910
+ title = identifier or "Untitled bad case"
2911
+ elif identifier and not title.startswith(identifier):
2912
+ title = f"{identifier}: {title}"
2913
+ return title
2914
+
2915
+
2916
+ def commit_loose_bad_case(cards: list[dict[str, str]], current: dict[str, str] | None) -> None:
2917
+ if not current:
2918
+ return
2919
+ if "title" not in current and "id" not in current:
2920
+ return
2921
+ current["title"] = loose_bad_case_title(current)
2922
+ cards.append(current)
2923
+
2924
+
2925
+ def parse_loose_bad_case_cards(text: str) -> list[dict[str, str]]:
2926
+ cards: list[dict[str, str]] = []
2927
+ current: dict[str, str] | None = None
2928
+ for line in text.splitlines():
2929
+ stripped = line.strip()
2930
+ if not stripped:
2931
+ continue
2932
+ body = ""
2933
+ if stripped.startswith("- "):
2934
+ body = stripped[2:].strip()
2935
+ elif current and re.match(r"^[A-Za-z][A-Za-z _/-]+:\s+", stripped):
2936
+ body = stripped
2937
+ else:
2938
+ continue
2939
+
2940
+ case_line = re.match(r"^(BC-\d{8}-\d+)\s*:\s*(.+)$", body)
2941
+ if case_line:
2942
+ commit_loose_bad_case(cards, current)
2943
+ current = {"id": case_line.group(1), "title": case_line.group(2).strip()}
2944
+ continue
2945
+
2946
+ field = split_loose_bad_case_field(body)
2947
+ if not field:
2948
+ continue
2949
+ key, value = field
2950
+ if key == "id" and re.search(r"BC-\d{8}-\d+", value):
2951
+ commit_loose_bad_case(cards, current)
2952
+ current = {"id": re.search(r"BC-\d{8}-\d+", value).group(0)}
2953
+ trailing = value.replace(current["id"], "", 1).strip(" -:")
2954
+ if trailing:
2955
+ current["title"] = trailing
2956
+ continue
2957
+ if current is None:
2958
+ current = {}
2959
+ current[key] = value
2960
+ commit_loose_bad_case(cards, current)
2961
+ return cards
2962
+
2963
+
2964
+ def render_bad_case_summary(card: dict[str, str], anchor: str) -> str:
2965
+ title = localized_text(human_title(card.get("title", "Bad case")))
2966
+ status = card.get("status", "unknown")
2967
+ frequency = card.get("frequency", "")
2968
+ tags = parse_tags(card.get("tags", ""))
2969
+ tag_html = render_tags(tags, limit=3)
2970
+ markers = f"{status_dot(status)}{frequency_dot(frequency)}"
2971
+ return f"""<article class="badcase">
2972
+ <div class="badcase-head"><a class="detail-link" href="#{html.escape(anchor)}">{title}</a><span class="badcase-markers" aria-hidden="true">{markers}</span></div>
2973
+ {f'<div class="tags">{tag_html}</div>' if tag_html else ''}
2974
+ </article>"""
2975
+
2976
+
2977
+ def render_bad_case_test_note(card: dict[str, str], anchor: str) -> str:
2978
+ title = localized_short_text(human_title(card.get("title", "Bad case")), 58)
2979
+ guard = first_nonempty(
2980
+ card.get("guard / verification", ""),
2981
+ card.get("guard", ""),
2982
+ card.get("green condition", ""),
2983
+ card.get("red condition", ""),
2984
+ card.get("trigger / reproduction", ""),
2985
+ card.get("trigger", ""),
2986
+ card.get("phenomenon", ""),
2987
+ )
2988
+ guard_text = localized_short_text(guard or "No guard recorded.", 110)
2989
+ reusable = card.get("reusable guard path", "").strip()
2990
+ reusable_html = ""
2991
+ if reusable and reusable.lower() not in {"none", "n/a", "null"}:
2992
+ reusable_html = f"<p><code>{html.escape(strip_wrapping_backticks(reusable))}</code></p>"
2993
+ return f"""<article class="test-note">
2994
+ <a class="detail-link" href="#{html.escape(anchor)}">{title}</a>
2995
+ <p>{guard_text}</p>
2996
+ {reusable_html}
2997
+ </article>"""
2998
+
2999
+
3000
+ def render_route_test_note(card: dict[str, str], anchor: str) -> str:
3001
+ title = localized_short_text(human_title(card.get("title", "Test")), 38)
3002
+ guard = first_nonempty(
3003
+ card.get("guard / verification", ""),
3004
+ card.get("green condition", ""),
3005
+ card.get("red condition", ""),
3006
+ card.get("trigger / reproduction", ""),
3007
+ )
3008
+ guard_text = localized_short_text(guard or "No guard recorded.", 64)
3009
+ return f"""<a class="route-test-note" href="#{html.escape(anchor)}">
3010
+ <span>{title}</span>
3011
+ <small>{guard_text}</small>
3012
+ </a>"""
3013
+
3014
+
3015
+ def first_nonempty(*values: str) -> str:
3016
+ for value in values:
3017
+ if value and value.strip():
3018
+ return value.strip()
3019
+ return ""
3020
+
3021
+
3022
+ def strip_wrapping_backticks(value: str) -> str:
3023
+ value = value.strip()
3024
+ if value.startswith("`") and value.endswith("`") and len(value) >= 2:
3025
+ return value[1:-1]
3026
+ return value
3027
+
3028
+
3029
+ def render_case_detail(card: dict[str, str], anchor: str) -> str:
3030
+ title = localized_text(human_title(card.get("title", "Bad case")))
3031
+ summary_raw = first_nonempty(
3032
+ human_text(card.get("phenomenon", "")),
3033
+ human_text(card.get("root cause", "")),
3034
+ human_text(card.get("fix method", "")),
3035
+ human_text(card.get("title", "")),
3036
+ )
3037
+ summary = localized_short_text(summary_raw or "No summary recorded.", 150)
3038
+ tags = parse_tags(card.get("tags", ""))
3039
+ tag_html = render_tags(tags)
3040
+ return f"""<section class="detail-card" id="{html.escape(anchor)}">
3041
+ <h3>{title}</h3>
3042
+ <p class="field"><b data-i18n="summary">Summary:</b> {summary}</p>
3043
+ {f'<div class="tags">{tag_html}</div>' if tag_html else ''}
3044
+ </section>"""
3045
+
3046
+
3047
+ def meaningful_bad_case_value(value: str | None) -> bool:
3048
+ if value is None:
3049
+ return False
3050
+ normalized = strip_wrapping_backticks(value).strip().lower()
3051
+ if not normalized:
3052
+ return False
3053
+ return normalized not in {"none", "n/a", "na", "null", "unknown", "unset", "tbd", "todo", "待定", "无", "未知"}
3054
+
3055
+
3056
+ def bad_case_status(card: dict[str, str]) -> str:
3057
+ return card.get("status", "").strip().lower().replace("_", "-")
3058
+
3059
+
3060
+ def is_recently_checked(card: dict[str, str]) -> bool:
3061
+ today = datetime.now().strftime("%Y-%m-%d")
3062
+ return card.get("last checked", "").strip() == today
3063
+
3064
+
3065
+ def validate_bad_case_guards(root: Path, strict: bool = False, verbose: bool = False) -> int:
3066
+ ctx = context_dir(root)
3067
+ path = ctx / "bad-cases.md"
3068
+ if not path.exists():
3069
+ print(f"[context-guard] no bad-case register found: {path}")
3070
+ return 0
3071
+
3072
+ cards = parse_bad_case_cards(path.read_text(encoding="utf-8"))
3073
+ if not cards:
3074
+ print("[context-guard] no bad cases recorded.")
3075
+ return 0
3076
+
3077
+ required_for_resolved = [
3078
+ "guard type",
3079
+ "guard / verification",
3080
+ "red condition",
3081
+ "green condition",
3082
+ "expected failure reason",
3083
+ ]
3084
+ errors: list[str] = []
3085
+ warnings: list[str] = []
3086
+ for index, card in enumerate(cards, 1):
3087
+ title = human_title(card.get("title", f"bad case {index}"))
3088
+ status = bad_case_status(card)
3089
+ if status in {"resolved", "recurred"}:
3090
+ missing = [field for field in required_for_resolved if not meaningful_bad_case_value(card.get(field))]
3091
+ if missing:
3092
+ message = f"{title}: missing {', '.join(missing)}"
3093
+ if strict or status == "recurred":
3094
+ errors.append(message)
3095
+ elif is_recently_checked(card):
3096
+ warnings.append(f"newly checked resolved case {message}")
3097
+ else:
3098
+ warnings.append(f"legacy resolved case {message}")
3099
+ elif status in {"open", "deferred"}:
3100
+ for field in ["phenomenon", "trigger / reproduction"]:
3101
+ if not meaningful_bad_case_value(card.get(field)):
3102
+ warnings.append(f"{title}: {status} case has no {field}")
3103
+ if "high-frequency" in card.get("frequency", "").lower() and not meaningful_bad_case_value(card.get("high-frequency note")):
3104
+ warnings.append(f"{title}: high-frequency case has no high-frequency note")
3105
+
3106
+ shown_warnings = warnings if verbose else warnings[:8]
3107
+ for warning in shown_warnings:
3108
+ print(f"[context-guard] warning: {warning}")
3109
+ if len(warnings) > len(shown_warnings):
3110
+ print(f"[context-guard] warning: {len(warnings) - len(shown_warnings)} more warning(s); rerun with --verbose to show all.")
3111
+ if errors:
3112
+ print("[context-guard] bad-case guard validation failed:")
3113
+ for error in errors:
3114
+ print(f"- {error}")
3115
+ return 1
3116
+ mode = "strict" if strict else "default"
3117
+ print(f"[context-guard] bad-case guard validation passed ({mode}): {len(cards)} case(s) checked.")
3118
+ return 0
3119
+
3120
+
3121
+ def trailing_checkpoint_count(items: list[tuple[int, dict[str, str]]]) -> int:
3122
+ count = 0
3123
+ for _, node in reversed(items):
3124
+ if node_level(node) == "major":
3125
+ break
3126
+ count += 1
3127
+ return count
3128
+
3129
+
3130
+ def validate_roadmap_maintenance(root: Path, max_hidden_checkpoints: int = 8) -> int:
3131
+ ctx = context_dir(root)
3132
+ roadmap_path = ctx / "roadmap.md"
3133
+ if not roadmap_path.exists():
3134
+ print(f"[context-guard] no roadmap found: {roadmap_path}")
3135
+ return 0
3136
+
3137
+ nodes = parse_roadmap_nodes(roadmap_path.read_text(encoding="utf-8"))
3138
+ if not nodes:
3139
+ print("[context-guard] no roadmap nodes recorded.")
3140
+ return 0
3141
+
3142
+ errors: list[str] = []
3143
+ warnings: list[str] = []
3144
+ for branch, items in group_nodes_by_branch(nodes):
3145
+ major_count = len([node for _, node in items if node_level(node) == "major"])
3146
+ trailing = trailing_checkpoint_count(items)
3147
+ if major_count == 0:
3148
+ errors.append(f"{branch}: no visible major node")
3149
+ if trailing > max_hidden_checkpoints:
3150
+ errors.append(
3151
+ f"{branch}: {trailing} checkpoint(s) after the latest visible node; promote or add a major route node"
3152
+ )
3153
+ elif trailing > 0:
3154
+ warnings.append(f"{branch}: {trailing} checkpoint(s) after latest visible node")
3155
+
3156
+ latest = nodes[-1]
3157
+ if node_level(latest) == "checkpoint":
3158
+ warnings.append(f"latest node is hidden in overview: {human_title(latest.get('title', 'Untitled roadmap node'))}")
3159
+
3160
+ for warning in warnings[:8]:
3161
+ print(f"[context-guard] warning: {warning}")
3162
+ if len(warnings) > 8:
3163
+ print(f"[context-guard] warning: {len(warnings) - 8} more warning(s).")
3164
+ if errors:
3165
+ print("[context-guard] roadmap maintenance validation failed:")
3166
+ for error in errors:
3167
+ print(f"- {error}")
3168
+ return 1
3169
+ print(f"[context-guard] roadmap maintenance validation passed: {len(nodes)} node(s), {len(group_nodes_by_branch(nodes))} route(s).")
3170
+ return 0
3171
+
3172
+
3173
+ def extract_section(text: str, heading: str) -> str:
3174
+ lines = text.splitlines()
3175
+ try:
3176
+ start = lines.index(heading) + 1
3177
+ except ValueError:
3178
+ return "No quick scan section."
3179
+ collected: list[str] = []
3180
+ for line in lines[start:]:
3181
+ if line.startswith("## "):
3182
+ break
3183
+ collected.append(line)
3184
+ return "\n".join(collected).strip() or "No quick scan entries."
3185
+
3186
+
3187
+ def extract_bad_case_scan(text: str) -> str:
3188
+ interesting: list[str] = []
3189
+ for line in text.splitlines():
3190
+ lower = line.lower()
3191
+ if line.startswith("### ") or "tags:" in lower or "frequency:" in lower or "roadmap nodes:" in lower:
3192
+ interesting.append(line)
3193
+ return "\n".join(interesting).strip() or "No bad-case links recorded."
3194
+
3195
+
3196
+ def ascii_slug(value: str, fallback: str = "branch") -> str:
3197
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
3198
+ return slug or fallback
3199
+
3200
+
3201
+ def next_task_id(ctx: Path, title: str, branch: str) -> str:
3202
+ stamp = datetime.now().strftime("%Y%m%d")
3203
+ base = f"CTX-{stamp}-{ascii_slug(branch or title)}"
3204
+ task_root = ctx / "tasks"
3205
+ candidate = base
3206
+ suffix = 2
3207
+ while (task_root / candidate).exists():
3208
+ candidate = f"{base}-{suffix}"
3209
+ suffix += 1
3210
+ return candidate
3211
+
3212
+
3213
+ def next_roadmap_node_id(roadmap: str) -> str:
3214
+ stamp = datetime.now().strftime("%Y%m%d")
3215
+ numbers = [int(value) for value in re.findall(rf"NODE-{stamp}-(\d+)", roadmap)]
3216
+ return f"NODE-{stamp}-{(max(numbers) if numbers else 0) + 1:03d}"
3217
+
3218
+
3219
+ def parse_current_index_entry(index: str) -> dict[str, str]:
3220
+ entry: dict[str, str] = {}
3221
+ match = re.search(r"(?ms)^## Current\s*\n\n(.*?)(?=\n## |\Z)", index)
3222
+ block = match.group(1).strip() if match else ""
3223
+ for line in block.splitlines():
3224
+ stripped = line.strip()
3225
+ if not stripped.startswith("- ") or ":" not in stripped:
3226
+ continue
3227
+ key, value = stripped[2:].split(":", 1)
3228
+ entry[key.strip().lower()] = value.strip()
3229
+ quick_current = re.search(r"(?m)^- Current:\s*(.+)$", index)
3230
+ if quick_current and "id" not in entry:
3231
+ entry["id"] = quick_current.group(1).strip()
3232
+ return entry
3233
+
3234
+
3235
+ def rewrite_quick_scan(index: str, task_id: str, node_id_value: str, resume_id: str) -> str:
3236
+ replacements = {
3237
+ "Current": task_id,
3238
+ "Latest roadmap node": node_id_value,
3239
+ "Resume candidate": resume_id or "none",
3240
+ }
3241
+ for label, value in replacements.items():
3242
+ pattern = rf"(?m)^- {re.escape(label)}:\s*.*$"
3243
+ line = f"- {label}: {value}"
3244
+ if re.search(pattern, index):
3245
+ index = re.sub(pattern, line, index)
3246
+ else:
3247
+ index = index.replace("## Quick Scan\n\n", f"## Quick Scan\n\n{line}\n", 1)
3248
+ return index
3249
+
3250
+
3251
+ def render_current_index_block(task_id: str, title: str, task_folder: str, branch: str, parent_node: str, zh: bool) -> str:
3252
+ today = datetime.now().strftime("%Y-%m-%d")
3253
+ if zh:
3254
+ summary = f"支线任务已创建,路线为“{branch}”,父节点为 {parent_node or 'none'}。"
3255
+ next_step = "在该支线内继续推进,并把相关 bad case 与测试链路链接到后续节点。"
3256
+ else:
3257
+ summary = f"Branch task created for {branch}; parent node is {parent_node or 'none'}."
3258
+ next_step = "Continue inside this branch and link related bad cases and recurrence checks to later nodes."
3259
+ return "\n".join(
3260
+ [
3261
+ f"- ID: {task_id}",
3262
+ f"- Title: {title}",
3263
+ "- State: current",
3264
+ f"- Folder: `{task_folder}`",
3265
+ f"- Last updated: {today}",
3266
+ f"- Summary: {summary}",
3267
+ f"- Next step: {next_step}",
3268
+ ]
3269
+ )
3270
+
3271
+
3272
+ def render_parked_index_entry(previous: dict[str, str], zh: bool) -> str:
3273
+ previous_id = previous.get("id", "").strip()
3274
+ if not previous_id or previous_id.lower() in {"none", "none yet."}:
3275
+ return ""
3276
+ today = datetime.now().strftime("%Y-%m-%d")
3277
+ title = previous.get("title", previous_id)
3278
+ folder = previous.get("folder", f"`.codex/context/tasks/{previous_id}/`")
3279
+ if zh:
3280
+ parked = "因为用户显式创建支线任务,原当前任务暂存为可恢复任务。"
3281
+ prompt = f"是否回到“{title}”?"
3282
+ else:
3283
+ parked = "Parked because the user explicitly created a branch task."
3284
+ prompt = f"Resume {title}?"
3285
+ return "\n".join(
3286
+ [
3287
+ f"### {previous_id}",
3288
+ "",
3289
+ f"- Title: {title}",
3290
+ "- State: resume-candidate",
3291
+ f"- Folder: {folder}",
3292
+ f"- Parked because: {parked}",
3293
+ f"- Resume prompt: {prompt}",
3294
+ f"- Last updated: {today}",
3295
+ ]
3296
+ )
3297
+
3298
+
3299
+ def update_index_for_branch_task(
3300
+ ctx: Path,
3301
+ task_id: str,
3302
+ title: str,
3303
+ branch: str,
3304
+ parent_node: str,
3305
+ node_id_value: str,
3306
+ ) -> tuple[str, str]:
3307
+ index_path = ctx / "index.md"
3308
+ index = index_path.read_text(encoding="utf-8")
3309
+ previous = parse_current_index_entry(index)
3310
+ previous_id = previous.get("id", "").strip()
3311
+ zh = preferred_display_language(ctx) == "zh"
3312
+ task_folder = f".codex/context/tasks/{task_id}/"
3313
+ current_block = render_current_index_block(task_id, title, task_folder, branch, parent_node, zh)
3314
+ index = rewrite_quick_scan(index, task_id, node_id_value, previous_id)
3315
+ index = re.sub(r"(?ms)(^## Current\s*\n\n).*?(?=\n## |\Z)", rf"\1{current_block}\n", index, count=1)
3316
+ parked_entry = render_parked_index_entry(previous, zh)
3317
+ if parked_entry and previous_id not in extract_section(index, "## Parked / Resume Candidates"):
3318
+ parked_match = re.search(r"(?ms)(^## Parked / Resume Candidates\s*\n\n)(.*?)(?=\n## |\Z)", index)
3319
+ if parked_match:
3320
+ body = parked_match.group(2).strip()
3321
+ body = "" if body == "None." else body
3322
+ new_body = f"{parked_entry}\n\n{body}".strip()
3323
+ index = index[: parked_match.start(2)] + new_body + "\n" + index[parked_match.end(2) :]
3324
+ index_path.write_text(index, encoding="utf-8")
3325
+ return previous_id, task_folder
3326
+
3327
+
3328
+ def write_branch_task_context(
3329
+ ctx: Path,
3330
+ task_id: str,
3331
+ title: str,
3332
+ branch: str,
3333
+ parent_node: str,
3334
+ parent_task: str,
3335
+ ) -> Path:
3336
+ zh = preferred_display_language(ctx) == "zh"
3337
+ today = datetime.now().strftime("%Y-%m-%d")
3338
+ task_dir = ctx / "tasks" / task_id
3339
+ task_dir.mkdir(parents=True, exist_ok=True)
3340
+ if zh:
3341
+ content = f"""# {title}
3342
+
3343
+ - State: current
3344
+ - Branch: {branch}
3345
+ - Parent task: {parent_task or 'none'}
3346
+ - Parent roadmap node: {parent_node or 'none'}
3347
+ - Last updated: {today}
3348
+
3349
+ ## Objective
3350
+
3351
+ 维护这条支线的关键进展、相关 bad case 和复现检查,避免把支线内容混回主线。
3352
+
3353
+ ## Key Context
3354
+
3355
+ - 用户显式要求创建或处理支线任务。
3356
+ - 后续路线节点需要继续使用 `Branch: {branch}`。
3357
+ - 如果该支线产生 bad case,必须把 bad case 链接到对应路线节点。
3358
+
3359
+ ## Next Step
3360
+
3361
+ 继续推进支线,并在完成前运行相关 bad-case guard。
3362
+ """
3363
+ else:
3364
+ content = f"""# {title}
3365
+
3366
+ - State: current
3367
+ - Branch: {branch}
3368
+ - Parent task: {parent_task or 'none'}
3369
+ - Parent roadmap node: {parent_node or 'none'}
3370
+ - Last updated: {today}
3371
+
3372
+ ## Objective
3373
+
3374
+ Maintain this branch route's key progress, related bad cases, and recurrence checks without mixing it back into the mainline.
3375
+
3376
+ ## Key Context
3377
+
3378
+ - The user explicitly requested a branch or side route.
3379
+ - Later roadmap nodes should keep using `Branch: {branch}`.
3380
+ - Branch bad cases must link back to their roadmap nodes.
3381
+
3382
+ ## Next Step
3383
+
3384
+ Continue the branch and run relevant bad-case guards before completion.
3385
+ """
3386
+ path = task_dir / "context.md"
3387
+ path.write_text(content, encoding="utf-8")
3388
+ return path
3389
+
3390
+
3391
+ def append_branch_roadmap_node(
3392
+ ctx: Path,
3393
+ task_id: str,
3394
+ title: str,
3395
+ branch: str,
3396
+ parent_node: str,
3397
+ ) -> str:
3398
+ roadmap_path = ctx / "roadmap.md"
3399
+ roadmap = roadmap_path.read_text(encoding="utf-8")
3400
+ node_id_value = next_roadmap_node_id(roadmap)
3401
+ today = datetime.now().strftime("%Y-%m-%d")
3402
+ zh = preferred_display_language(ctx) == "zh"
3403
+ if zh:
3404
+ outcome = f"创建“{branch}”支线任务,后续进展与风险独立记录。"
3405
+ decision = "用户显式说明这是支线,不能继续混入当前主线。"
3406
+ avoid = "不要只写 hook 提醒而不创建支线任务文件夹和路线节点。"
3407
+ next_step = "在该支线内推进,并把相关 bad case 与测试链路链接到节点。"
3408
+ test_chain = "运行支线任务创建 guard,确认 index、task 文件夹、Branch/Parent 节点和 roadmap 导出打通。"
3409
+ else:
3410
+ outcome = f"Created the {branch} branch task so later progress and risks stay separate."
3411
+ decision = "The user explicitly marked the work as a branch, so it must not continue as mainline-only context."
3412
+ avoid = "Do not only print hook reminders without creating the task folder and route node."
3413
+ next_step = "Continue inside this branch and link related bad cases and recurrence checks."
3414
+ test_chain = "Run the branch task guard to verify index, task folder, Branch/Parent node, and roadmap export."
3415
+ node = f"""
3416
+ ### {node_id_value}: {title}
3417
+
3418
+ - Date: {today}
3419
+ - Status: active
3420
+ - Level: major
3421
+ - Branch: {branch}
3422
+ - Parent: {parent_node or 'none'}
3423
+ - Task: `{task_id}`
3424
+ - Display title: {title}
3425
+ - User request: {title}
3426
+ - Outcome: {outcome}
3427
+ - Decision / reason: {decision}
3428
+ - Avoid going back: {avoid}
3429
+ - Next: {next_step}
3430
+ - Linked bad cases: none
3431
+ - Test chain: {test_chain}
3432
+ """
3433
+ roadmap = roadmap.replace("\nNo nodes yet.\n", "\n", 1)
3434
+ roadmap = roadmap.rstrip() + "\n" + node
3435
+ roadmap_path.write_text(roadmap, encoding="utf-8")
3436
+ return node_id_value
3437
+
3438
+
3439
+ def quick_scan_value(index: str, label: str, fallback: str = "none") -> str:
3440
+ match = re.search(rf"(?m)^- {re.escape(label)}:\s*(.+)$", index)
3441
+ return match.group(1).strip() if match else fallback
3442
+
3443
+
3444
+ def strip_markdown_path(value: str) -> str:
3445
+ return value.strip().strip("`")
3446
+
3447
+
3448
+ def infer_current_branch(ctx: Path, explicit_branch: str | None) -> str:
3449
+ if explicit_branch and explicit_branch.strip():
3450
+ return explicit_branch.strip()
3451
+ index = (ctx / "index.md").read_text(encoding="utf-8")
3452
+ current = parse_current_index_entry(index)
3453
+ folder = strip_markdown_path(current.get("folder", ""))
3454
+ if folder:
3455
+ task_context = ctx.parent.parent / folder / "context.md" if folder.startswith(".codex/") else Path(folder) / "context.md"
3456
+ if task_context.exists():
3457
+ match = re.search(r"(?m)^- Branch:\s*(.+)$", task_context.read_text(encoding="utf-8"))
3458
+ if match and match.group(1).strip():
3459
+ return match.group(1).strip()
3460
+ return "Main"
3461
+
3462
+
3463
+ def update_current_index_checkpoint(ctx: Path, node_id_value: str, outcome: str, next_step: str) -> None:
3464
+ index_path = ctx / "index.md"
3465
+ index = index_path.read_text(encoding="utf-8")
3466
+ current_id = parse_current_index_entry(index).get("id", quick_scan_value(index, "Current"))
3467
+ resume_id = quick_scan_value(index, "Resume candidate")
3468
+ index = rewrite_quick_scan(index, current_id or "none", node_id_value, resume_id)
3469
+ match = re.search(r"(?ms)^## Current\s*\n\n(.*?)(?=\n## |\Z)", index)
3470
+ if match:
3471
+ today = datetime.now().strftime("%Y-%m-%d")
3472
+ block = match.group(1).strip()
3473
+ lines = block.splitlines() if block else []
3474
+ fields = {
3475
+ "Last updated": today,
3476
+ "Summary": outcome,
3477
+ "Next step": next_step,
3478
+ }
3479
+ seen: set[str] = set()
3480
+ rewritten: list[str] = []
3481
+ for line in lines:
3482
+ stripped = line.strip()
3483
+ replaced = False
3484
+ for label, value in fields.items():
3485
+ if stripped.startswith(f"- {label}:"):
3486
+ rewritten.append(f"- {label}: {value}")
3487
+ seen.add(label)
3488
+ replaced = True
3489
+ break
3490
+ if not replaced:
3491
+ rewritten.append(line)
3492
+ for label, value in fields.items():
3493
+ if value and label not in seen:
3494
+ rewritten.append(f"- {label}: {value}")
3495
+ new_block = "\n".join(rewritten).strip()
3496
+ index = index[: match.start(1)] + new_block + "\n" + index[match.end(1) :]
3497
+ index_path.write_text(index, encoding="utf-8")
3498
+
3499
+
3500
+ def append_checkpoint_roadmap_node(
3501
+ ctx: Path,
3502
+ title: str,
3503
+ branch: str,
3504
+ level: str,
3505
+ outcome: str,
3506
+ display_title: str = "",
3507
+ user_request: str = "",
3508
+ progress_summary: str = "",
3509
+ method_summary: str = "",
3510
+ decision: str = "",
3511
+ avoid: str = "",
3512
+ next_step: str = "",
3513
+ linked_bad_cases: str = "",
3514
+ test_chain: str = "",
3515
+ parent_node: str = "",
3516
+ ) -> str:
3517
+ if not title.strip():
3518
+ raise ValueError("checkpoint-roadmap-node requires a non-empty --title")
3519
+ if level not in {"major", "checkpoint"}:
3520
+ raise ValueError("--level must be major or checkpoint")
3521
+ roadmap_path = ctx / "roadmap.md"
3522
+ roadmap = roadmap_path.read_text(encoding="utf-8")
3523
+ node_id_value = next_roadmap_node_id(roadmap)
3524
+ today = datetime.now().strftime("%Y-%m-%d")
3525
+ index = (ctx / "index.md").read_text(encoding="utf-8")
3526
+ task_id = parse_current_index_entry(index).get("id", quick_scan_value(index, "Current"))
3527
+ zh = preferred_display_language(ctx) == "zh"
3528
+ default_next = "继续维护相关路线、bad case 和测试链路。" if zh else "Keep the route, bad cases, and recurrence checks updated."
3529
+ outcome = outcome.strip() or ("完成一次路线维护 checkpoint。" if zh else "Recorded a route checkpoint.")
3530
+ next_step = next_step.strip() or default_next
3531
+ lines = [
3532
+ f"### {node_id_value}: {title.strip()}",
3533
+ "",
3534
+ f"- Date: {today}",
3535
+ "- Status: done",
3536
+ f"- Level: {level}",
3537
+ f"- Branch: {branch.strip() or 'Main'}",
3538
+ ]
3539
+ if parent_node.strip():
3540
+ lines.append(f"- Parent: {parent_node.strip()}")
3541
+ if task_id and task_id.lower() not in {"none", "none yet."}:
3542
+ lines.append(f"- Task: `{task_id}`")
3543
+ if display_title.strip():
3544
+ lines.append(f"- Display title: {display_title.strip()}")
3545
+ if user_request.strip():
3546
+ lines.append(f"- User request: {user_request.strip()}")
3547
+ if progress_summary.strip():
3548
+ lines.append(f"- Progress summary: {progress_summary.strip()}")
3549
+ if method_summary.strip():
3550
+ lines.append(f"- Method summary: {method_summary.strip()}")
3551
+ lines.append(f"- Outcome: {outcome}")
3552
+ if decision.strip():
3553
+ lines.append(f"- Decision / reason: {decision.strip()}")
3554
+ if avoid.strip():
3555
+ lines.append(f"- Avoid going back: {avoid.strip()}")
3556
+ lines.append(f"- Next: {next_step}")
3557
+ lines.append(f"- Linked bad cases: {linked_bad_cases.strip() or 'none'}")
3558
+ if test_chain.strip():
3559
+ lines.append(f"- Test chain: {test_chain.strip()}")
3560
+ node = "\n".join(lines) + "\n"
3561
+ roadmap = roadmap.replace("\nNo nodes yet.\n", "\n", 1)
3562
+ roadmap = roadmap.rstrip() + "\n\n" + node
3563
+ roadmap_path.write_text(roadmap, encoding="utf-8")
3564
+ update_current_index_checkpoint(ctx, node_id_value, outcome, next_step)
3565
+ return node_id_value
3566
+
3567
+
3568
+ def checkpoint_roadmap_node(
3569
+ root: Path,
3570
+ title: str,
3571
+ branch: str | None,
3572
+ level: str,
3573
+ outcome: str,
3574
+ display_title: str = "",
3575
+ user_request: str = "",
3576
+ progress_summary: str = "",
3577
+ method_summary: str = "",
3578
+ decision: str = "",
3579
+ avoid: str = "",
3580
+ next_step: str = "",
3581
+ linked_bad_cases: str = "",
3582
+ test_chain: str = "",
3583
+ parent_node: str = "",
3584
+ ) -> str:
3585
+ init_context(root)
3586
+ ctx = context_dir(root)
3587
+ route = infer_current_branch(ctx, branch)
3588
+ node_id_value = append_checkpoint_roadmap_node(
3589
+ ctx,
3590
+ title=title,
3591
+ branch=route,
3592
+ level=level,
3593
+ outcome=outcome,
3594
+ display_title=display_title,
3595
+ user_request=user_request,
3596
+ progress_summary=progress_summary,
3597
+ method_summary=method_summary,
3598
+ decision=decision,
3599
+ avoid=avoid,
3600
+ next_step=next_step,
3601
+ linked_bad_cases=linked_bad_cases,
3602
+ test_chain=test_chain,
3603
+ parent_node=parent_node,
3604
+ )
3605
+ export_roadmap(root, "html")
3606
+ print(f"[context-guard] roadmap node: {node_id_value}")
3607
+ print(f"[context-guard] route: {route}")
3608
+ return node_id_value
3609
+
3610
+
3611
+ APPROVED_TEST_STATUSES = {"approved", "active", "stable"}
3612
+ RUN_ALWAYS_POLICY = "every-dev-completion"
3613
+ BLOCKER_PATTERNS = [
3614
+ "MISSING_CREDENTIAL",
3615
+ "PERMISSION_DENIED",
3616
+ "SERVICE_UNAVAILABLE",
3617
+ "NETWORK_UNAVAILABLE",
3618
+ "RESOURCE_UNAVAILABLE",
3619
+ "USER_CONFIRMATION_REQUIRED",
3620
+ "DESTRUCTIVE_CONFIRMATION_REQUIRED",
3621
+ ]
3622
+
3623
+
3624
+ def normalize_test_slug(value: str) -> str:
3625
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower()).strip("-")
3626
+ return slug or "test"
3627
+
3628
+
3629
+ def test_registry_path(ctx: Path) -> Path:
3630
+ return ctx / "test-hub" / "registry.json"
3631
+
3632
+
3633
+ def default_test_registry() -> dict[str, object]:
3634
+ return {
3635
+ "version": 1,
3636
+ "description": "Human-approved Context Guard test registry. Codex runs approved every-dev-completion tests through dev-complete.",
3637
+ "tests": [],
3638
+ }
3639
+
3640
+
3641
+ def load_test_registry(ctx: Path) -> dict[str, object]:
3642
+ path = test_registry_path(ctx)
3643
+ if not path.exists():
3644
+ return default_test_registry()
3645
+ try:
3646
+ data = json.loads(path.read_text(encoding="utf-8"))
3647
+ except json.JSONDecodeError:
3648
+ print(f"[context-guard] invalid test registry JSON: {path}", file=sys.stderr)
3649
+ return default_test_registry()
3650
+ if not isinstance(data, dict):
3651
+ return default_test_registry()
3652
+ tests = data.get("tests")
3653
+ if not isinstance(tests, list):
3654
+ data["tests"] = []
3655
+ data.setdefault("version", 1)
3656
+ return data
3657
+
3658
+
3659
+ def write_test_registry(ctx: Path, registry: dict[str, object]) -> Path:
3660
+ path = test_registry_path(ctx)
3661
+ path.parent.mkdir(parents=True, exist_ok=True)
3662
+ path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
3663
+ return path
3664
+
3665
+
3666
+ def next_test_id(ctx: Path, title: str) -> str:
3667
+ today = datetime.now().strftime("%Y%m%d")
3668
+ registry = load_test_registry(ctx)
3669
+ existing = " ".join(
3670
+ str(test.get("id", "")) for test in registry.get("tests", []) if isinstance(test, dict)
3671
+ )
3672
+ numbers = [int(match.group(1)) for match in re.finditer(rf"TC-{today}-(\d+)", existing)]
3673
+ return f"TC-{today}-{(max(numbers) + 1 if numbers else 1):03d}-{normalize_test_slug(title)[:36]}"
3674
+
3675
+
3676
+ def test_hub_add(
3677
+ root: Path,
3678
+ title: str,
3679
+ command_text: str,
3680
+ run_policy: str = RUN_ALWAYS_POLICY,
3681
+ status: str = "approved",
3682
+ timeout_seconds: int = 300,
3683
+ artifact_policy: str = "cleanup-on-pass",
3684
+ resource: str = "local",
3685
+ ) -> Path:
3686
+ init_context(root)
3687
+ ctx = context_dir(root)
3688
+ if not title.strip():
3689
+ raise ValueError("test-hub-add requires --title")
3690
+ if not command_text.strip():
3691
+ raise ValueError("test-hub-add requires --command-text")
3692
+ registry = load_test_registry(ctx)
3693
+ tests = registry.setdefault("tests", [])
3694
+ if not isinstance(tests, list):
3695
+ tests = []
3696
+ registry["tests"] = tests
3697
+ test_id = next_test_id(ctx, title)
3698
+ tests.append(
3699
+ {
3700
+ "id": test_id,
3701
+ "title": title.strip(),
3702
+ "status": status.strip() or "approved",
3703
+ "run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
3704
+ "type": "command",
3705
+ "command": command_text.strip(),
3706
+ "cwd": ".",
3707
+ "resource": resource.strip() or "local",
3708
+ "timeout_seconds": int(timeout_seconds),
3709
+ "artifact_policy": artifact_policy.strip() or "cleanup-on-pass",
3710
+ "blocker_keywords": BLOCKER_PATTERNS,
3711
+ "created_at": datetime.now().isoformat(timespec="seconds"),
3712
+ "source": "human-approved registry entry",
3713
+ }
3714
+ )
3715
+ path = write_test_registry(ctx, registry)
3716
+ print(f"[context-guard] registered test: {test_id}")
3717
+ print(f"[context-guard] registry: {path}")
3718
+ return path
3719
+
3720
+
3721
+ def test_hub_registry_tests(ctx: Path) -> list[dict[str, object]]:
3722
+ registry = load_test_registry(ctx)
3723
+ return [item for item in registry.get("tests", []) if isinstance(item, dict)]
3724
+
3725
+
3726
+ def test_hub_find_registry_test(ctx: Path, test_id: str) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
3727
+ registry = load_test_registry(ctx)
3728
+ tests = registry.setdefault("tests", [])
3729
+ if not isinstance(tests, list):
3730
+ tests = []
3731
+ registry["tests"] = tests
3732
+ matches = [item for item in tests if isinstance(item, dict) and str(item.get("id", "")) == test_id]
3733
+ if not matches:
3734
+ raise ValueError(f"test not found in registry: {test_id}")
3735
+ return matches[0], tests, registry
3736
+
3737
+
3738
+ def test_hub_list(root: Path) -> int:
3739
+ init_context(root)
3740
+ ctx = context_dir(root)
3741
+ registry_tests = test_hub_registry_tests(ctx)
3742
+ task_case_tests = discover_task_case_tests(ctx)
3743
+ if not registry_tests and not task_case_tests:
3744
+ print("[context-guard] test hub: no registered tests.")
3745
+ print(f"[context-guard] registry: {test_registry_path(ctx)}")
3746
+ return 0
3747
+ if registry_tests:
3748
+ print("[context-guard] registry tests:")
3749
+ for test in registry_tests:
3750
+ test_id = str(test.get("id", "unknown"))
3751
+ title = str(test.get("title", "untitled"))
3752
+ status = str(test.get("status", "unknown"))
3753
+ run_policy = str(test.get("run_policy", test.get("run policy", "unset")))
3754
+ command = str(test.get("command", "")).strip()
3755
+ command_suffix = f" | {command}" if command else ""
3756
+ print(f"- {test_id} | {status} | {run_policy} | {title}{command_suffix}")
3757
+ if task_case_tests:
3758
+ print("[context-guard] approved task-case tests:")
3759
+ for test in task_case_tests:
3760
+ test_id = str(test.get("id", "unknown"))
3761
+ title = str(test.get("title", "untitled"))
3762
+ source = str(test.get("source", "task-cases"))
3763
+ print(f"- {test_id} | {test.get('status')} | {test.get('run_policy')} | {title} | {source}")
3764
+ print(f"[context-guard] registry: {test_registry_path(ctx)}")
3765
+ return 0
3766
+
3767
+
3768
+ def read_test_hub_last_run(root: Path) -> dict[str, object]:
3769
+ path = test_hub_dir(root) / "last-run.json"
3770
+ if not path.exists():
3771
+ return {}
3772
+ try:
3773
+ data = json.loads(path.read_text(encoding="utf-8"))
3774
+ except json.JSONDecodeError:
3775
+ return {}
3776
+ return data if isinstance(data, dict) else {}
3777
+
3778
+
3779
+ def test_hub_state(root: Path) -> dict[str, object]:
3780
+ init_context(root)
3781
+ ctx = context_dir(root)
3782
+ registry_tests = test_hub_registry_tests(ctx)
3783
+ task_case_tests = discover_task_case_tests(ctx)
3784
+ tests: list[dict[str, object]] = []
3785
+ for source, items in (("registry", registry_tests), ("task-case", task_case_tests)):
3786
+ for item in items:
3787
+ status = str(item.get("status", "unknown")).strip() or "unknown"
3788
+ run_policy = str(item.get("run_policy", item.get("run policy", "unset"))).strip() or "unset"
3789
+ command = str(item.get("command", "")).strip()
3790
+ tests.append(
3791
+ {
3792
+ "id": str(item.get("id", "")),
3793
+ "title": str(item.get("title", "untitled")),
3794
+ "status": status,
3795
+ "run_policy": run_policy,
3796
+ "type": str(item.get("type") or ("command" if command else "manual")),
3797
+ "command": command,
3798
+ "resource": str(item.get("resource", "local") or "local"),
3799
+ "source": source,
3800
+ "eligible": status.lower() in APPROVED_TEST_STATUSES and run_policy == RUN_ALWAYS_POLICY,
3801
+ }
3802
+ )
3803
+ last_run = read_test_hub_last_run(root)
3804
+ results = last_run.get("results", [])
3805
+ if not isinstance(results, list):
3806
+ results = []
3807
+ counts = {
3808
+ "total": len(tests),
3809
+ "eligible": sum(1 for test in tests if test.get("eligible")),
3810
+ "passed": sum(1 for item in results if isinstance(item, dict) and item.get("status") == "passed"),
3811
+ "failed": sum(1 for item in results if isinstance(item, dict) and item.get("status") == "failed"),
3812
+ "blocked": sum(1 for item in results if isinstance(item, dict) and item.get("status") == "blocked"),
3813
+ }
3814
+ return {
3815
+ "root": str(root),
3816
+ "registry": str(test_registry_path(ctx)),
3817
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
3818
+ "tests": tests,
3819
+ "last_run": last_run,
3820
+ "counts": counts,
3821
+ }
3822
+
3823
+
3824
+ def test_hub_badge(text: object, class_name: str = "") -> str:
3825
+ return f'<span class="badge {class_name}">{html.escape(str(text))}</span>'
3826
+
3827
+
3828
+ def render_test_hub_html(root: Path) -> str:
3829
+ state = test_hub_state(root)
3830
+ tests = [item for item in state["tests"] if isinstance(item, dict)]
3831
+ last_run = state.get("last_run", {})
3832
+ results = last_run.get("results", []) if isinstance(last_run, dict) else []
3833
+ result_by_id = {
3834
+ str(item.get("id", "")): item
3835
+ for item in results
3836
+ if isinstance(item, dict)
3837
+ }
3838
+ rows = []
3839
+ for test in tests:
3840
+ eligible = bool(test.get("eligible"))
3841
+ status = str(test.get("status", "unknown"))
3842
+ run_policy = str(test.get("run_policy", "unset"))
3843
+ result = result_by_id.get(str(test.get("id", "")), {})
3844
+ result_status = str(result.get("status", "未运行")) if result else "未运行"
3845
+ result_class = {
3846
+ "passed": "ok",
3847
+ "failed": "bad",
3848
+ "blocked": "warn",
3849
+ "未运行": "muted",
3850
+ }.get(result_status, "muted")
3851
+ command = str(test.get("command", "")).strip()
3852
+ command_html = f"<code>{html.escape(command)}</code>" if command else '<span class="muted">人工判断 / 未注册脚本</span>'
3853
+ rows.append(
3854
+ f"""
3855
+ <article class="test-card">
3856
+ <div class="card-head">
3857
+ <div>
3858
+ <h2>{html.escape(str(test.get("title", "未命名测试")))}</h2>
3859
+ <div class="meta">
3860
+ {test_hub_badge("每次开发后运行" if eligible else "不自动运行", "ok" if eligible else "muted")}
3861
+ {test_hub_badge(status)}
3862
+ {test_hub_badge(str(test.get("source", "")))}
3863
+ </div>
3864
+ </div>
3865
+ <span class="result {result_class}">{html.escape(result_status)}</span>
3866
+ </div>
3867
+ <p class="command">{command_html}</p>
3868
+ <p class="subtle">策略:{html.escape(run_policy)} · 资源:{html.escape(str(test.get("resource", "local")))}</p>
3869
+ </article>
3870
+ """
3871
+ )
3872
+ if not rows:
3873
+ rows.append(
3874
+ """
3875
+ <article class="empty">
3876
+ <h2>还没有测试</h2>
3877
+ <p>用户确认测试后,会显示在这里。未确认的 bad case guard 不会自动变成测试。</p>
3878
+ </article>
3879
+ """
3880
+ )
3881
+
3882
+ last_run_text = "暂无运行记录"
3883
+ if isinstance(last_run, dict) and last_run.get("created_at"):
3884
+ counts = state.get("counts", {})
3885
+ last_run_text = (
3886
+ f"{last_run.get('created_at')} · "
3887
+ f"{counts.get('passed', 0)} 通过 / {counts.get('failed', 0)} 失败 / {counts.get('blocked', 0)} 阻塞"
3888
+ )
3889
+ state_json = json.dumps(state, ensure_ascii=False).replace("</", "<\\/")
3890
+ return f"""<!doctype html>
3891
+ <html lang="zh-CN">
3892
+ <head>
3893
+ <meta charset="utf-8" />
3894
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3895
+ <title>Context Guard 测试中台</title>
3896
+ <style>
3897
+ :root {{
3898
+ color-scheme: light;
3899
+ --bg: #f4f7ef;
3900
+ --panel: #fffff8;
3901
+ --card: #fffef7;
3902
+ --ink: #243125;
3903
+ --muted: #64705d;
3904
+ --line: #cddcc5;
3905
+ --accent: #37745b;
3906
+ --accent-soft: #e4f2e8;
3907
+ --warn: #b0733f;
3908
+ --warn-soft: #f8ead9;
3909
+ --ok: #2f7d63;
3910
+ --ok-soft: #dff1e7;
3911
+ --danger: #b94c4c;
3912
+ --quiet: #99a78f;
3913
+ --shadow: 0 14px 34px rgba(51, 83, 57, 0.12);
3914
+ --radius: 8px;
3915
+ --font-body: "Avenir Next", "Gill Sans", "PingFang SC", "Hiragino Sans GB", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3916
+ --font-heading: "Iowan Old Style", "Charter", "Songti SC", "STSong", Georgia, serif;
3917
+ --board-texture: radial-gradient(circle at 20% 15%, rgba(93, 135, 83, 0.12), transparent 24%), radial-gradient(circle at 82% 4%, rgba(183, 143, 92, 0.12), transparent 20%);
3918
+ }}
3919
+ * {{ box-sizing: border-box; }}
3920
+ body {{
3921
+ margin: 0;
3922
+ background: var(--bg);
3923
+ color: var(--ink);
3924
+ font: 14px/1.58 var(--font-body);
3925
+ text-rendering: optimizeLegibility;
3926
+ }}
3927
+ header {{
3928
+ padding: 22px 32px 14px;
3929
+ border-bottom: 1px solid var(--line);
3930
+ background: var(--panel);
3931
+ }}
3932
+ .header-row {{
3933
+ display: flex;
3934
+ align-items: flex-start;
3935
+ justify-content: space-between;
3936
+ gap: 16px;
3937
+ }}
3938
+ .shell {{
3939
+ padding: 16px 32px 30px;
3940
+ }}
3941
+ h1 {{
3942
+ margin: 0 0 4px;
3943
+ font-family: var(--font-heading);
3944
+ font-size: 24px;
3945
+ letter-spacing: 0;
3946
+ }}
3947
+ h2 {{
3948
+ margin: 0;
3949
+ font-family: var(--font-heading);
3950
+ font-size: 15px;
3951
+ line-height: 1.35;
3952
+ letter-spacing: 0;
3953
+ }}
3954
+ p {{ margin: 0; }}
3955
+ .subtle, .muted {{ color: var(--muted); }}
3956
+ .actions {{ display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }}
3957
+ button {{
3958
+ appearance: none;
3959
+ border: 1px solid var(--accent);
3960
+ border-radius: 999px;
3961
+ padding: 6px 13px;
3962
+ color: white;
3963
+ background: var(--accent);
3964
+ font: inherit;
3965
+ font-size: 12px;
3966
+ font-weight: 760;
3967
+ cursor: pointer;
3968
+ box-shadow: 0 0 0 4px var(--accent-soft);
3969
+ }}
3970
+ button:disabled {{ cursor: not-allowed; opacity: .45; }}
3971
+ .hub-board {{
3972
+ background: var(--panel);
3973
+ border: 1px solid var(--line);
3974
+ border-radius: var(--radius);
3975
+ box-shadow: var(--shadow);
3976
+ padding: 16px;
3977
+ background-image: var(--board-texture);
3978
+ }}
3979
+ .board-head {{
3980
+ display: flex;
3981
+ align-items: center;
3982
+ justify-content: space-between;
3983
+ gap: 12px;
3984
+ margin-bottom: 14px;
3985
+ }}
3986
+ .summary {{
3987
+ display: grid;
3988
+ grid-template-columns: repeat(4, minmax(0, 1fr));
3989
+ gap: 10px;
3990
+ margin-bottom: 14px;
3991
+ }}
3992
+ .metric, .test-card, .empty, .log {{
3993
+ background: var(--card);
3994
+ border: 1px solid var(--line);
3995
+ border-radius: var(--radius);
3996
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75), 0 8px 20px rgba(51, 83, 57, 0.08);
3997
+ }}
3998
+ .metric {{ padding: 10px 12px; }}
3999
+ .metric span {{ color: var(--muted); font-size: 11px; }}
4000
+ .metric strong {{
4001
+ display: block;
4002
+ margin-top: 2px;
4003
+ font-family: var(--font-heading);
4004
+ font-size: 20px;
4005
+ line-height: 1.1;
4006
+ color: var(--accent);
4007
+ }}
4008
+ .grid {{ display: grid; gap: 14px; }}
4009
+ .test-card {{
4010
+ border-top: 4px solid var(--accent);
4011
+ padding: 12px;
4012
+ }}
4013
+ .card-head {{ display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }}
4014
+ .meta {{ display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }}
4015
+ .badge {{
4016
+ display: inline-flex;
4017
+ align-items: center;
4018
+ border-radius: 999px;
4019
+ padding: 2px 8px;
4020
+ background: color-mix(in srgb, var(--accent-soft) 62%, #fff);
4021
+ color: var(--muted);
4022
+ font-size: 11px;
4023
+ font-weight: 680;
4024
+ }}
4025
+ .badge.ok {{ background: var(--ok-soft); color: var(--ok); }}
4026
+ .badge.muted {{ color: var(--muted); }}
4027
+ .result {{
4028
+ flex: 0 0 auto;
4029
+ min-width: 58px;
4030
+ text-align: center;
4031
+ border-radius: 999px;
4032
+ padding: 3px 9px;
4033
+ font-size: 11px;
4034
+ font-weight: 760;
4035
+ background: color-mix(in srgb, var(--accent-soft) 62%, #fff);
4036
+ }}
4037
+ .result.ok {{ background: var(--ok-soft); color: var(--ok); }}
4038
+ .result.bad {{ background: #f8dfe0; color: var(--danger); }}
4039
+ .result.warn {{ background: var(--warn-soft); color: var(--warn); }}
4040
+ .result.muted {{ color: var(--muted); }}
4041
+ .command {{ margin: 12px 0 7px; }}
4042
+ code {{
4043
+ display: inline-block;
4044
+ max-width: 100%;
4045
+ overflow: auto;
4046
+ color: var(--accent);
4047
+ background: color-mix(in srgb, var(--accent-soft) 52%, #fff);
4048
+ border-radius: var(--radius);
4049
+ padding: 6px 8px;
4050
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
4051
+ font-size: 12px;
4052
+ }}
4053
+ .empty {{ padding: 22px; text-align: center; }}
4054
+ .log {{ display: none; margin-top: 14px; padding: 12px; white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }}
4055
+ .log.visible {{ display: block; }}
4056
+ @media (max-width: 760px) {{
4057
+ header, .shell {{ padding-left: 16px; padding-right: 16px; }}
4058
+ .header-row, .card-head, .board-head {{ flex-direction: column; align-items: flex-start; }}
4059
+ .summary {{ grid-template-columns: repeat(2, minmax(0, 1fr)); }}
4060
+ }}
4061
+ </style>
4062
+ </head>
4063
+ <body>
4064
+ <header>
4065
+ <div class="header-row">
4066
+ <div>
4067
+ <h1>测试中台</h1>
4068
+ <p class="subtle">查看用户已确认的测试;localhost 控制台支持一键运行。</p>
4069
+ </div>
4070
+ <div class="actions">
4071
+ <button id="run-all" type="button">运行所有测试</button>
4072
+ <p id="mode" class="subtle"></p>
4073
+ </div>
4074
+ </div>
4075
+ </header>
4076
+
4077
+ <main class="shell">
4078
+ <section class="hub-board">
4079
+ <div class="board-head">
4080
+ <h2>测试列表</h2>
4081
+ <p class="subtle">上次运行:{html.escape(last_run_text)}</p>
4082
+ </div>
4083
+ <section class="summary" aria-label="测试概览">
4084
+ <div class="metric"><span>总测试</span><strong>{state["counts"]["total"]}</strong></div>
4085
+ <div class="metric"><span>自动运行</span><strong>{state["counts"]["eligible"]}</strong></div>
4086
+ <div class="metric"><span>上次通过</span><strong>{state["counts"]["passed"]}</strong></div>
4087
+ <div class="metric"><span>上次失败/阻塞</span><strong>{state["counts"]["failed"] + state["counts"]["blocked"]}</strong></div>
4088
+ </section>
4089
+
4090
+ <section class="grid" aria-label="测试列表">
4091
+ {''.join(rows)}
4092
+ </section>
4093
+ <pre id="log" class="log"></pre>
4094
+ </section>
4095
+ </main>
4096
+ <script>
4097
+ window.__TEST_HUB_STATE__ = {state_json};
4098
+ const runButton = document.getElementById('run-all');
4099
+ const mode = document.getElementById('mode');
4100
+ const log = document.getElementById('log');
4101
+ const canRun = location.protocol === 'http:' || location.protocol === 'https:';
4102
+ if (!canRun) {{
4103
+ runButton.disabled = true;
4104
+ mode.textContent = '一键运行需要 serve-test-hub 启动 localhost 控制台';
4105
+ }} else {{
4106
+ mode.textContent = 'localhost 控制台已启用';
4107
+ }}
4108
+ runButton.addEventListener('click', async () => {{
4109
+ if (!canRun) return;
4110
+ runButton.disabled = true;
4111
+ runButton.textContent = '运行中...';
4112
+ log.classList.add('visible');
4113
+ log.textContent = '正在运行已确认测试...';
4114
+ try {{
4115
+ const response = await fetch('/api/run', {{ method: 'POST' }});
4116
+ const data = await response.json();
4117
+ log.textContent = data.output || JSON.stringify(data, null, 2);
4118
+ setTimeout(() => location.reload(), 900);
4119
+ }} catch (error) {{
4120
+ log.textContent = String(error);
4121
+ }} finally {{
4122
+ runButton.disabled = false;
4123
+ runButton.textContent = '运行所有测试';
4124
+ }}
4125
+ }});
4126
+ </script>
4127
+ </body>
4128
+ </html>
4129
+ """
4130
+
4131
+
4132
+ def show_test_hub(root: Path, open_browser: bool = False) -> Path:
4133
+ init_context(root)
4134
+ hub = test_hub_dir(root)
4135
+ hub.mkdir(parents=True, exist_ok=True)
4136
+ path = hub / "test-hub.html"
4137
+ path.write_text(render_test_hub_html(root), encoding="utf-8")
4138
+ print(path)
4139
+ if open_browser:
4140
+ webbrowser.open(path.resolve().as_uri())
4141
+ return path
4142
+
4143
+
4144
+ def serve_test_hub(root: Path, host: str, port: int, jobs: int, open_browser: bool = False) -> int:
4145
+ init_context(root)
4146
+ run_lock = threading.Lock()
4147
+
4148
+ class TestHubHandler(BaseHTTPRequestHandler):
4149
+ def log_message(self, format: str, *args: object) -> None:
4150
+ return
4151
+
4152
+ def send_json(self, status: int, payload: dict[str, object]) -> None:
4153
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
4154
+ self.send_response(status)
4155
+ self.send_header("Content-Type", "application/json; charset=utf-8")
4156
+ self.send_header("Content-Length", str(len(body)))
4157
+ self.end_headers()
4158
+ self.wfile.write(body)
4159
+
4160
+ def do_GET(self) -> None:
4161
+ path = urlparse(self.path).path
4162
+ if path in {"/", "/test-hub.html"}:
4163
+ body = render_test_hub_html(root).encode("utf-8")
4164
+ self.send_response(200)
4165
+ self.send_header("Content-Type", "text/html; charset=utf-8")
4166
+ self.send_header("Content-Length", str(len(body)))
4167
+ self.end_headers()
4168
+ self.wfile.write(body)
4169
+ return
4170
+ if path == "/api/state":
4171
+ self.send_json(200, test_hub_state(root))
4172
+ return
4173
+ self.send_json(404, {"error": "not found"})
4174
+
4175
+ def do_POST(self) -> None:
4176
+ path = urlparse(self.path).path
4177
+ if path != "/api/run":
4178
+ self.send_json(404, {"error": "not found"})
4179
+ return
4180
+ if not run_lock.acquire(blocking=False):
4181
+ self.send_json(409, {"status": "busy", "output": "测试正在运行,请稍后。"})
4182
+ return
4183
+ stdout = io.StringIO()
4184
+ stderr = io.StringIO()
4185
+ try:
4186
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
4187
+ code = test_hub_dev_complete(root, jobs=jobs)
4188
+ output = stdout.getvalue() + stderr.getvalue()
4189
+ self.send_json(
4190
+ 200 if code == 0 else 500,
4191
+ {
4192
+ "status": "passed" if code == 0 else "failed",
4193
+ "code": code,
4194
+ "output": output,
4195
+ },
4196
+ )
4197
+ finally:
4198
+ run_lock.release()
4199
+
4200
+ server = ThreadingHTTPServer((host, port), TestHubHandler)
4201
+ actual_host, actual_port = server.server_address[:2]
4202
+ url = f"http://{actual_host}:{actual_port}/test-hub.html"
4203
+ print(f"[context-guard] test hub: {url}")
4204
+ if open_browser:
4205
+ webbrowser.open(url)
4206
+ try:
4207
+ server.serve_forever()
4208
+ except KeyboardInterrupt:
4209
+ print("\n[context-guard] test hub stopped.")
4210
+ finally:
4211
+ server.server_close()
4212
+ return 0
4213
+
4214
+
4215
+ def test_hub_update_test(root: Path, test_id: str, **updates: object) -> Path:
4216
+ init_context(root)
4217
+ ctx = context_dir(root)
4218
+ item, _tests, registry = test_hub_find_registry_test(ctx, test_id)
4219
+ item.update({key: value for key, value in updates.items() if value is not None})
4220
+ item["updated_at"] = datetime.now().isoformat(timespec="seconds")
4221
+ path = write_test_registry(ctx, registry)
4222
+ print(f"[context-guard] updated test: {test_id}")
4223
+ print(f"[context-guard] registry: {path}")
4224
+ return path
4225
+
4226
+
4227
+ def test_hub_remove(root: Path, test_id: str) -> Path:
4228
+ init_context(root)
4229
+ ctx = context_dir(root)
4230
+ _item, tests, registry = test_hub_find_registry_test(ctx, test_id)
4231
+ registry["tests"] = [item for item in tests if not (isinstance(item, dict) and str(item.get("id", "")) == test_id)]
4232
+ path = write_test_registry(ctx, registry)
4233
+ print(f"[context-guard] removed test: {test_id}")
4234
+ print(f"[context-guard] registry: {path}")
4235
+ return path
4236
+
4237
+
4238
+ def parse_markdown_fields(text: str) -> dict[str, str]:
4239
+ fields: dict[str, str] = {}
4240
+ for line in text.splitlines():
4241
+ match = re.match(r"^\s*-\s*([^:]+):\s*(.*)$", line)
4242
+ if not match:
4243
+ continue
4244
+ key = " ".join(match.group(1).strip().lower().split())
4245
+ fields[key] = match.group(2).strip()
4246
+ return fields
4247
+
4248
+
4249
+ def command_from_task_case_fields(fields: dict[str, str]) -> str:
4250
+ automation = fields.get("automation entry", "").strip().lower()
4251
+ entry = first_nonempty(fields.get("entry command/prompt", ""), fields.get("entry command", ""))
4252
+ if not entry or entry.lower() in {"none", "n/a", "manual"}:
4253
+ return ""
4254
+ if "manual" in automation or "prompt" in automation or automation == "none":
4255
+ return ""
4256
+ return strip_wrapping_backticks(entry)
4257
+
4258
+
4259
+ def discover_task_case_tests(ctx: Path) -> list[dict[str, object]]:
4260
+ task_case_dir = ctx / "task-cases"
4261
+ if not task_case_dir.exists():
4262
+ return []
4263
+ tests: list[dict[str, object]] = []
4264
+ for path in sorted(task_case_dir.glob("*.md")):
4265
+ fields = parse_markdown_fields(path.read_text(encoding="utf-8"))
4266
+ status = fields.get("status", "").strip().lower()
4267
+ run_policy = fields.get("run policy", "").strip() or RUN_ALWAYS_POLICY
4268
+ if status not in APPROVED_TEST_STATUSES or run_policy != RUN_ALWAYS_POLICY:
4269
+ continue
4270
+ command = command_from_task_case_fields(fields)
4271
+ title = fields.get("title", "") or path.stem
4272
+ test_id = fields.get("id", "") or path.stem
4273
+ tests.append(
4274
+ {
4275
+ "id": test_id,
4276
+ "title": title,
4277
+ "status": status,
4278
+ "run_policy": run_policy,
4279
+ "type": "command" if command else "manual",
4280
+ "command": command,
4281
+ "cwd": ".",
4282
+ "resource": fields.get("resource", "local") or "local",
4283
+ "timeout_seconds": 300,
4284
+ "artifact_policy": fields.get("artifact policy", "cleanup-on-pass") or "cleanup-on-pass",
4285
+ "blocker_keywords": BLOCKER_PATTERNS,
4286
+ "source": str(path.relative_to(ctx.parent.parent)) if len(path.parts) > 2 else str(path),
4287
+ }
4288
+ )
4289
+ return tests
4290
+
4291
+
4292
+ def approved_registry_tests(ctx: Path) -> list[dict[str, object]]:
4293
+ registry = load_test_registry(ctx)
4294
+ tests: list[dict[str, object]] = []
4295
+ for item in registry.get("tests", []):
4296
+ if not isinstance(item, dict):
4297
+ continue
4298
+ status = str(item.get("status", "")).strip().lower()
4299
+ run_policy = str(item.get("run_policy", item.get("run policy", ""))).strip()
4300
+ if status in APPROVED_TEST_STATUSES and run_policy == RUN_ALWAYS_POLICY:
4301
+ normalized = dict(item)
4302
+ normalized["run_policy"] = run_policy
4303
+ normalized.setdefault("type", "command" if normalized.get("command") else "manual")
4304
+ normalized.setdefault("timeout_seconds", 300)
4305
+ normalized.setdefault("artifact_policy", "cleanup-on-pass")
4306
+ normalized.setdefault("resource", "local")
4307
+ normalized.setdefault("blocker_keywords", BLOCKER_PATTERNS)
4308
+ tests.append(normalized)
4309
+ return tests
4310
+
4311
+
4312
+ def approved_dev_completion_tests(ctx: Path) -> list[dict[str, object]]:
4313
+ by_id: dict[str, dict[str, object]] = {}
4314
+ for test in approved_registry_tests(ctx) + discover_task_case_tests(ctx):
4315
+ test_id = str(test.get("id") or test.get("title") or "unnamed")
4316
+ by_id[test_id] = test
4317
+ return list(by_id.values())
4318
+
4319
+
4320
+ def safe_relative_path(root: Path, value: str) -> Path | None:
4321
+ if not value:
4322
+ return None
4323
+ path = Path(strip_wrapping_backticks(value)).expanduser()
4324
+ if not path.is_absolute():
4325
+ path = root / path
4326
+ try:
4327
+ resolved = path.resolve()
4328
+ resolved.relative_to(root.resolve())
4329
+ except ValueError:
4330
+ return None
4331
+ return resolved
4332
+
4333
+
4334
+ def cleanup_registered_paths(root: Path, cleanup_paths: object) -> list[str]:
4335
+ cleaned: list[str] = []
4336
+ if not isinstance(cleanup_paths, list):
4337
+ return cleaned
4338
+ for value in cleanup_paths:
4339
+ path = safe_relative_path(root, str(value))
4340
+ if not path or not path.exists():
4341
+ continue
4342
+ if path.is_dir():
4343
+ shutil.rmtree(path)
4344
+ else:
4345
+ path.unlink()
4346
+ cleaned.append(str(path))
4347
+ return cleaned
4348
+
4349
+
4350
+ def classify_test_blocker(output: str, keywords: object) -> str:
4351
+ patterns = list(BLOCKER_PATTERNS)
4352
+ if isinstance(keywords, list):
4353
+ patterns.extend(str(item) for item in keywords)
4354
+ for pattern in patterns:
4355
+ if pattern and pattern.lower() in output.lower():
4356
+ return pattern
4357
+ return ""
4358
+
4359
+
4360
+ def run_one_hub_test(root: Path, run_dir: Path, test: dict[str, object]) -> dict[str, object]:
4361
+ test_id = str(test.get("id") or test.get("title") or "unnamed")
4362
+ title = str(test.get("title") or test_id)
4363
+ command = str(test.get("command") or "").strip()
4364
+ test_type = str(test.get("type") or ("command" if command else "manual")).lower()
4365
+ log_path = run_dir / f"{normalize_test_slug(test_id)}.log"
4366
+ if test_type in {"manual", "prompt"} or not command:
4367
+ result = {
4368
+ "id": test_id,
4369
+ "title": title,
4370
+ "status": "blocked",
4371
+ "reason": "user-judgment",
4372
+ "message": "No automated command is registered for this approved test.",
4373
+ "log": str(log_path),
4374
+ }
4375
+ log_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
4376
+ return result
4377
+
4378
+ cwd_value = str(test.get("cwd") or ".")
4379
+ cwd = safe_relative_path(root, cwd_value) or root
4380
+ timeout = int(test.get("timeout_seconds") or 300)
4381
+ env = {
4382
+ **dict(os.environ),
4383
+ "CONTEXT_GUARD_PROJECT_ROOT": str(root),
4384
+ "CONTEXT_GUARD_TEST_ID": test_id,
4385
+ "CONTEXT_GUARD_TEST_RUN_DIR": str(run_dir),
4386
+ }
4387
+ try:
4388
+ completed = subprocess.run(
4389
+ command,
4390
+ cwd=str(cwd),
4391
+ shell=True,
4392
+ text=True,
4393
+ capture_output=True,
4394
+ timeout=timeout,
4395
+ env=env,
4396
+ )
4397
+ output = (completed.stdout or "") + (completed.stderr or "")
4398
+ blocker = classify_test_blocker(output, test.get("blocker_keywords"))
4399
+ status = "passed" if completed.returncode == 0 else ("blocked" if blocker or completed.returncode == 78 else "failed")
4400
+ cleaned = cleanup_registered_paths(root, test.get("cleanup_paths")) if status == "passed" else []
4401
+ log_path.write_text(output or "(no output)\n", encoding="utf-8")
4402
+ return {
4403
+ "id": test_id,
4404
+ "title": title,
4405
+ "status": status,
4406
+ "returncode": completed.returncode,
4407
+ "reason": blocker or ("exit-78" if completed.returncode == 78 else ""),
4408
+ "command": command,
4409
+ "cwd": str(cwd),
4410
+ "resource": str(test.get("resource") or "local"),
4411
+ "log": str(log_path),
4412
+ "cleaned": cleaned,
4413
+ }
4414
+ except subprocess.TimeoutExpired as exc:
4415
+ output = (exc.stdout or "") + (exc.stderr or "")
4416
+ log_path.write_text(output + f"\nTIMEOUT after {timeout}s\n", encoding="utf-8")
4417
+ return {
4418
+ "id": test_id,
4419
+ "title": title,
4420
+ "status": "failed",
4421
+ "reason": "timeout",
4422
+ "command": command,
4423
+ "cwd": str(cwd),
4424
+ "resource": str(test.get("resource") or "local"),
4425
+ "log": str(log_path),
4426
+ }
4427
+
4428
+
4429
+ def test_hub_dev_complete(root: Path, jobs: int = 1, keep_success_artifacts: bool = False) -> int:
4430
+ init_context(root)
4431
+ ctx = context_dir(root)
4432
+ hub = test_hub_dir(root)
4433
+ hub.mkdir(parents=True, exist_ok=True)
4434
+ tests = approved_dev_completion_tests(ctx)
4435
+ if not tests:
4436
+ write_test_registry(ctx, load_test_registry(ctx))
4437
+ print("[context-guard] test hub: no approved every-dev-completion tests.")
4438
+ return 0
4439
+
4440
+ run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
4441
+ run_dir = hub / "runs" / run_id
4442
+ run_dir.mkdir(parents=True, exist_ok=True)
4443
+ max_workers = max(1, int(jobs))
4444
+ results: list[dict[str, object]] = []
4445
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
4446
+ futures = [executor.submit(run_one_hub_test, root, run_dir, test) for test in tests]
4447
+ for future in as_completed(futures):
4448
+ results.append(future.result())
4449
+
4450
+ results.sort(key=lambda item: str(item.get("id", "")))
4451
+ summary = {
4452
+ "run_id": run_id,
4453
+ "root": str(root),
4454
+ "created_at": datetime.now().isoformat(timespec="seconds"),
4455
+ "jobs": max_workers,
4456
+ "results": results,
4457
+ }
4458
+ summary_path = hub / "last-run.json"
4459
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
4460
+
4461
+ failed = [item for item in results if item.get("status") == "failed"]
4462
+ blocked = [item for item in results if item.get("status") == "blocked"]
4463
+ passed = [item for item in results if item.get("status") == "passed"]
4464
+ print(f"[context-guard] test hub: {len(passed)} passed, {len(failed)} failed, {len(blocked)} blocked.")
4465
+ for item in results:
4466
+ detail = f"{item.get('status')}: {item.get('title')}"
4467
+ if item.get("reason"):
4468
+ detail += f" ({item.get('reason')})"
4469
+ print(f"- {detail}")
4470
+ if failed or blocked:
4471
+ print(f"[context-guard] evidence preserved: {run_dir}")
4472
+ print(f"[context-guard] summary: {summary_path}")
4473
+ return 1
4474
+ if keep_success_artifacts:
4475
+ print(f"[context-guard] success artifacts kept: {run_dir}")
4476
+ else:
4477
+ shutil.rmtree(run_dir, ignore_errors=True)
4478
+ print("[context-guard] success artifacts cleaned.")
4479
+ print(f"[context-guard] summary: {summary_path}")
4480
+ return 0
4481
+
4482
+
4483
+ def create_branch_task(root: Path, title: str, branch: str, parent_node: str = "") -> tuple[str, str, Path]:
4484
+ init_context(root)
4485
+ ctx = context_dir(root)
4486
+ if not title.strip():
4487
+ raise ValueError("create-branch-task requires a non-empty title")
4488
+ branch = branch.strip() or title.strip()
4489
+ title = title.strip()
4490
+ index = (ctx / "index.md").read_text(encoding="utf-8")
4491
+ parent_task = parse_current_index_entry(index).get("id", "")
4492
+ task_id = next_task_id(ctx, title, branch)
4493
+ task_path = write_branch_task_context(ctx, task_id, title, branch, parent_node, parent_task)
4494
+ node_id_value = append_branch_roadmap_node(ctx, task_id, title, branch, parent_node)
4495
+ update_index_for_branch_task(ctx, task_id, title, branch, parent_node, node_id_value)
4496
+ export_roadmap(root, "html")
4497
+ print(f"[context-guard] branch task: {task_id}")
4498
+ print(f"[context-guard] branch node: {node_id_value}")
4499
+ print(f"[context-guard] task context: {task_path}")
4500
+ return task_id, node_id_value, task_path
4501
+
4502
+
4503
+ def main() -> int:
4504
+ parser = argparse.ArgumentParser(description="Context Guard utilities")
4505
+ parser.add_argument("command", choices=["init", "set-language", "export-roadmap", "show-roadmap", "create-branch-task", "checkpoint-roadmap-node", "validate-bad-cases", "validate-roadmap-maintenance", "test-hub-add", "test-hub-list", "test-hub-enable", "test-hub-disable", "test-hub-set-policy", "test-hub-remove", "show-test-hub", "serve-test-hub", "dev-complete"])
4506
+ parser.add_argument("--format", choices=["html", "md"], default="html")
4507
+ parser.add_argument("--language", default=None, help="Folder-scoped language for future context records.")
4508
+ parser.add_argument("--title", default=None, help="Title for a branch task or roadmap checkpoint.")
4509
+ parser.add_argument("--test-id", default="", help="Test ID for test-hub management commands.")
4510
+ parser.add_argument("--command-text", default="", help="Shell command for a human-approved test registry entry.")
4511
+ parser.add_argument("--run-policy", default=RUN_ALWAYS_POLICY, help="Run policy for a test registry entry.")
4512
+ parser.add_argument("--test-status", default="approved", help="Status for a test registry entry.")
4513
+ parser.add_argument("--reason", default="", help="Reason for disabling or changing a test policy.")
4514
+ parser.add_argument("--timeout-seconds", type=int, default=300, help="Timeout for a registered test command.")
4515
+ parser.add_argument("--artifact-policy", default="cleanup-on-pass", help="Artifact policy for a registered test command.")
4516
+ parser.add_argument("--resource", default="local", help="Resource/node label for a registered test command.")
4517
+ parser.add_argument("--jobs", type=int, default=1, help="Parallel test workers for dev-complete.")
4518
+ parser.add_argument("--host", default="127.0.0.1", help="Host for serve-test-hub.")
4519
+ parser.add_argument("--port", type=int, default=8772, help="Port for serve-test-hub.")
4520
+ parser.add_argument("--keep-success-artifacts", action="store_true", help="Keep test-hub run directory after all tests pass.")
4521
+ parser.add_argument("--branch", default=None, help="Branch/route name for a branch task or roadmap checkpoint.")
4522
+ parser.add_argument("--parent-node", default="", help="Roadmap node where the branch forks.")
4523
+ parser.add_argument("--level", choices=["major", "checkpoint"], default="checkpoint", help="Roadmap checkpoint level.")
4524
+ parser.add_argument("--outcome", default="", help="One-line result for a roadmap checkpoint.")
4525
+ parser.add_argument("--display-title", default="", help="Short human-facing title for roadmap overview cards.")
4526
+ parser.add_argument("--user-request", default="", help="Concise summary of the user's actual request for this checkpoint.")
4527
+ parser.add_argument("--progress-summary", default="", help="Readable human-facing current-progress text for node details.")
4528
+ parser.add_argument("--method-summary", default="", help="Readable human-facing method text for node details.")
4529
+ parser.add_argument("--decision", default="", help="Decision or reason for a roadmap checkpoint.")
4530
+ parser.add_argument("--avoid", default="", help="Avoid-going-back note for a roadmap checkpoint.")
4531
+ parser.add_argument("--next-step", default="", help="Next step for the active context.")
4532
+ parser.add_argument("--linked-bad-cases", default="", help="Comma-separated bad-case IDs linked to this checkpoint.")
4533
+ parser.add_argument("--test-chain", default="", help="Concise verification or recurrence-check note for this checkpoint.")
4534
+ parser.add_argument("--open", action="store_true", help="Open the generated HTML roadmap with the default browser.")
4535
+ parser.add_argument("--strict", action="store_true", help="Fail when any resolved bad case lacks red-capable guard fields.")
4536
+ parser.add_argument("--verbose", action="store_true", help="Show all validation warnings.")
4537
+ parser.add_argument("--max-hidden-checkpoints", type=int, default=8, help="Maximum checkpoints allowed after a route's latest visible node.")
4538
+ parser.add_argument("--root", type=Path, default=None)
4539
+ args = parser.parse_args()
4540
+
4541
+ root = args.root.resolve() if args.root else folder_root(Path.cwd())
4542
+ root_guard = guard_implicit_skill_root(root, explicit_root=args.root is not None)
4543
+ if root_guard:
4544
+ return root_guard
4545
+ if args.command == "init":
4546
+ created = init_context(root)
4547
+ if created:
4548
+ print("[context-guard] initialized context:")
4549
+ for path in created:
4550
+ print(f"- {path}")
4551
+ else:
4552
+ print(f"[context-guard] context already exists: {context_dir(root)}")
4553
+ return 0
4554
+ if args.command == "export-roadmap":
4555
+ print(export_roadmap(root, args.format))
4556
+ return 0
4557
+ if args.command == "set-language":
4558
+ if not args.language:
4559
+ parser.error("set-language requires --language")
4560
+ print(set_record_language(root, args.language))
4561
+ return 0
4562
+ if args.command == "show-roadmap":
4563
+ show_roadmap(root, args.open)
4564
+ return 0
4565
+ if args.command == "create-branch-task":
4566
+ if not args.title:
4567
+ parser.error("create-branch-task requires --title")
4568
+ create_branch_task(root, args.title, args.branch or args.title, args.parent_node)
4569
+ return 0
4570
+ if args.command == "checkpoint-roadmap-node":
4571
+ if not args.title:
4572
+ parser.error("checkpoint-roadmap-node requires --title")
4573
+ checkpoint_roadmap_node(
4574
+ root,
4575
+ title=args.title,
4576
+ branch=args.branch,
4577
+ level=args.level,
4578
+ outcome=args.outcome,
4579
+ display_title=args.display_title,
4580
+ user_request=args.user_request,
4581
+ progress_summary=args.progress_summary,
4582
+ method_summary=args.method_summary,
4583
+ decision=args.decision,
4584
+ avoid=args.avoid,
4585
+ next_step=args.next_step,
4586
+ linked_bad_cases=args.linked_bad_cases,
4587
+ test_chain=args.test_chain,
4588
+ parent_node=args.parent_node,
4589
+ )
4590
+ return 0
4591
+ if args.command == "test-hub-add":
4592
+ if not args.title:
4593
+ parser.error("test-hub-add requires --title")
4594
+ if not args.command_text:
4595
+ parser.error("test-hub-add requires --command-text")
4596
+ test_hub_add(
4597
+ root,
4598
+ title=args.title,
4599
+ command_text=args.command_text,
4600
+ run_policy=args.run_policy,
4601
+ status=args.test_status,
4602
+ timeout_seconds=args.timeout_seconds,
4603
+ artifact_policy=args.artifact_policy,
4604
+ resource=args.resource,
4605
+ )
4606
+ return 0
4607
+ if args.command == "test-hub-list":
4608
+ return test_hub_list(root)
4609
+ if args.command == "test-hub-enable":
4610
+ if not args.test_id:
4611
+ parser.error("test-hub-enable requires --test-id")
4612
+ test_hub_update_test(
4613
+ root,
4614
+ args.test_id,
4615
+ status=args.test_status if args.test_status != "approved" else "approved",
4616
+ run_policy=args.run_policy or RUN_ALWAYS_POLICY,
4617
+ disabled_reason="",
4618
+ )
4619
+ return 0
4620
+ if args.command == "test-hub-disable":
4621
+ if not args.test_id:
4622
+ parser.error("test-hub-disable requires --test-id")
4623
+ test_hub_update_test(
4624
+ root,
4625
+ args.test_id,
4626
+ status="disabled",
4627
+ run_policy="disabled-with-reason",
4628
+ disabled_reason=args.reason or "disabled by user",
4629
+ )
4630
+ return 0
4631
+ if args.command == "test-hub-set-policy":
4632
+ if not args.test_id:
4633
+ parser.error("test-hub-set-policy requires --test-id")
4634
+ test_hub_update_test(
4635
+ root,
4636
+ args.test_id,
4637
+ run_policy=args.run_policy,
4638
+ policy_reason=args.reason,
4639
+ )
4640
+ return 0
4641
+ if args.command == "test-hub-remove":
4642
+ if not args.test_id:
4643
+ parser.error("test-hub-remove requires --test-id")
4644
+ test_hub_remove(root, args.test_id)
4645
+ return 0
4646
+ if args.command == "show-test-hub":
4647
+ show_test_hub(root, args.open)
4648
+ return 0
4649
+ if args.command == "serve-test-hub":
4650
+ return serve_test_hub(root, host=args.host, port=args.port, jobs=args.jobs, open_browser=args.open)
4651
+ if args.command == "dev-complete":
4652
+ return test_hub_dev_complete(root, jobs=args.jobs, keep_success_artifacts=args.keep_success_artifacts)
4653
+ if args.command == "validate-bad-cases":
4654
+ return validate_bad_case_guards(root, strict=args.strict, verbose=args.verbose)
4655
+ if args.command == "validate-roadmap-maintenance":
4656
+ return validate_roadmap_maintenance(root, max_hidden_checkpoints=args.max_hidden_checkpoints)
4657
+ return 1
4658
+
4659
+
4660
+ if __name__ == "__main__":
4661
+ raise SystemExit(main())