@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,465 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight lifecycle reminders for the Context Guard plugin.
3
+
4
+ The hook initializes folder-scoped context on session start and nudges Codex to
5
+ use the context-guard skill at the two moments where omission is most costly:
6
+ prompt intake and turn stop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from context_guard import context_dir, init_context
18
+
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ WORKSPACE_KEYS = {
22
+ "cwd",
23
+ "current_working_directory",
24
+ "working_directory",
25
+ "workspace",
26
+ "workspace_root",
27
+ "workspaceFolder",
28
+ "workspace_folder",
29
+ "project",
30
+ "project_root",
31
+ "projectRoot",
32
+ "project_path",
33
+ "repository",
34
+ "repo",
35
+ "repo_path",
36
+ "root",
37
+ }
38
+ WORKSPACE_ENV_KEYS = [
39
+ "CODEX_WORKSPACE_ROOT",
40
+ "CODEX_PROJECT_ROOT",
41
+ "CODEX_CWD",
42
+ "WORKSPACE_ROOT",
43
+ "PROJECT_ROOT",
44
+ "PWD",
45
+ ]
46
+
47
+
48
+ def is_inside(path: Path, parent: Path) -> bool:
49
+ try:
50
+ path.resolve().relative_to(parent.resolve())
51
+ return True
52
+ except ValueError:
53
+ return False
54
+
55
+
56
+ def is_context_guard_skill_path(path: Path) -> bool:
57
+ return is_inside(path, SKILL_ROOT)
58
+
59
+
60
+ def git_root(cwd: Path) -> Path:
61
+ try:
62
+ out = subprocess.check_output(
63
+ ["git", "rev-parse", "--show-toplevel"],
64
+ cwd=str(cwd),
65
+ stderr=subprocess.DEVNULL,
66
+ text=True,
67
+ timeout=2,
68
+ ).strip()
69
+ if out:
70
+ return Path(out)
71
+ except Exception:
72
+ pass
73
+ return cwd
74
+
75
+
76
+ def possible_workspace_paths(value: object) -> list[Path]:
77
+ paths: list[Path] = []
78
+
79
+ def add_path(candidate: object) -> None:
80
+ if not isinstance(candidate, str):
81
+ return
82
+ text = candidate.strip()
83
+ if not text or not text.startswith("/"):
84
+ return
85
+ path = Path(text).expanduser()
86
+ if path.exists():
87
+ paths.append(path)
88
+
89
+ def walk(obj: object, key_hint: str = "") -> None:
90
+ if isinstance(obj, dict):
91
+ for key, child in obj.items():
92
+ if key in WORKSPACE_KEYS:
93
+ add_path(child)
94
+ walk(child, key)
95
+ elif isinstance(obj, list):
96
+ for child in obj:
97
+ walk(child, key_hint)
98
+
99
+ walk(value)
100
+ return paths
101
+
102
+
103
+ def parse_hook_payload(raw: str) -> object:
104
+ if not raw.strip():
105
+ return {}
106
+ try:
107
+ return json.loads(raw)
108
+ except Exception:
109
+ return {}
110
+
111
+
112
+ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
113
+ payload = parse_hook_payload(raw)
114
+ candidates: list[tuple[Path, str]] = []
115
+ for path in possible_workspace_paths(payload):
116
+ candidates.append((path, "hook payload"))
117
+ for key in WORKSPACE_ENV_KEYS:
118
+ value = os.environ.get(key, "").strip()
119
+ if value.startswith("/"):
120
+ path = Path(value).expanduser()
121
+ if path.exists():
122
+ candidates.append((path, f"${key}"))
123
+ candidates.append((cwd, "process cwd"))
124
+
125
+ for path, source in candidates:
126
+ root = git_root(path)
127
+ if not is_context_guard_skill_path(root):
128
+ return root, source
129
+
130
+ root = git_root(cwd)
131
+ return root, "process cwd"
132
+
133
+
134
+ def read_stdin() -> str:
135
+ try:
136
+ return sys.stdin.read()
137
+ except Exception:
138
+ return ""
139
+
140
+
141
+ def hook_log(message: str) -> None:
142
+ print(message, file=sys.stderr)
143
+
144
+
145
+ def hook_response(**payload: object) -> int:
146
+ print(json.dumps(payload, ensure_ascii=False))
147
+ return 0
148
+
149
+
150
+ def prompt_text(raw: str) -> str:
151
+ if not raw.strip():
152
+ return ""
153
+ try:
154
+ data = json.loads(raw)
155
+ except Exception:
156
+ return raw
157
+ pieces: list[str] = []
158
+
159
+ def walk(value: object) -> None:
160
+ if isinstance(value, str):
161
+ pieces.append(value)
162
+ elif isinstance(value, list):
163
+ for item in value:
164
+ walk(item)
165
+ elif isinstance(value, dict):
166
+ for key in ("prompt", "message", "text", "content", "input"):
167
+ if key in value:
168
+ walk(value[key])
169
+
170
+ walk(data)
171
+ return "\n".join(pieces)
172
+
173
+
174
+ def looks_like_bad_case(text: str) -> bool:
175
+ lowered = text.lower()
176
+ markers = [
177
+ "bug",
178
+ "bad case",
179
+ "regression",
180
+ "broken",
181
+ "error",
182
+ "failed",
183
+ "failing",
184
+ "doesn't work",
185
+ "not working",
186
+ "紧急",
187
+ "报错",
188
+ "失败",
189
+ "坏例",
190
+ "复现",
191
+ "回归",
192
+ "不对",
193
+ "有问题",
194
+ ]
195
+ return any(marker in lowered for marker in markers)
196
+
197
+
198
+ def looks_like_task_switch(text: str) -> bool:
199
+ lowered = text.lower()
200
+ markers = [
201
+ "urgent",
202
+ "instead",
203
+ "switch",
204
+ "pause",
205
+ "later",
206
+ "different",
207
+ "unrelated",
208
+ "先",
209
+ "暂停",
210
+ "等下",
211
+ "换个",
212
+ "另一个",
213
+ "紧急",
214
+ "回头",
215
+ "先不",
216
+ ]
217
+ return any(marker in lowered for marker in markers)
218
+
219
+
220
+ def looks_like_goal_mode(text: str) -> bool:
221
+ lowered = text.lower()
222
+ markers = [
223
+ "goal mode",
224
+ "goal模式",
225
+ "goal 模式",
226
+ "active goal",
227
+ "long-running",
228
+ "autonomous",
229
+ "目标模式",
230
+ "长期目标",
231
+ "持续执行",
232
+ "自动继续",
233
+ ]
234
+ return any(marker in lowered for marker in markers)
235
+
236
+
237
+ def looks_like_remote_work(text: str) -> bool:
238
+ lowered = text.lower()
239
+ markers = [
240
+ "ssh",
241
+ "remote server",
242
+ "remote host",
243
+ "dev server",
244
+ "jump host",
245
+ "服务器",
246
+ "远程",
247
+ "开发机",
248
+ "跳板机",
249
+ "通过ssh",
250
+ "通过 ssh",
251
+ ]
252
+ return any(marker in lowered for marker in markers)
253
+
254
+
255
+ def looks_like_test_creation(text: str) -> bool:
256
+ lowered = text.lower()
257
+ creation_markers = [
258
+ "create",
259
+ "write",
260
+ "generate",
261
+ "design",
262
+ "add",
263
+ "创建",
264
+ "建立",
265
+ "写",
266
+ "生成",
267
+ "设计",
268
+ "新增",
269
+ "加一个",
270
+ "做一个",
271
+ ]
272
+ test_markers = [
273
+ "test case",
274
+ "task case",
275
+ "test task",
276
+ "testing task",
277
+ "测试case",
278
+ "测试 case",
279
+ "测试任务",
280
+ "测试用例",
281
+ "测评任务",
282
+ "测评case",
283
+ "测评 case",
284
+ "测试链路",
285
+ "测试",
286
+ ]
287
+ return any(marker in lowered for marker in creation_markers) and any(marker in lowered for marker in test_markers)
288
+
289
+
290
+ def looks_like_explicit_branch(text: str) -> bool:
291
+ lowered = text.lower()
292
+ markers = [
293
+ "branch task",
294
+ "side task",
295
+ "side route",
296
+ "fork this",
297
+ "create a branch",
298
+ "new branch",
299
+ "as a branch",
300
+ "支线",
301
+ "分支",
302
+ "开一个分支",
303
+ "开一条支线",
304
+ "创建支线",
305
+ "创建分支",
306
+ "作为支线",
307
+ ]
308
+ return any(marker in lowered for marker in markers)
309
+
310
+
311
+ def looks_like_route_drift(text: str) -> bool:
312
+ lowered = text.lower()
313
+ drift_markers = [
314
+ "significantly diverge",
315
+ "diverge from",
316
+ "different architecture",
317
+ "new architecture",
318
+ "new direction",
319
+ "refactor direction",
320
+ "偏离",
321
+ "显著偏离",
322
+ "新的架构",
323
+ "新方向",
324
+ "重构方向",
325
+ "主线架构",
326
+ ]
327
+ return any(marker in lowered for marker in drift_markers)
328
+
329
+
330
+ def bad_case_blocks(text: str) -> list[dict[str, str]]:
331
+ blocks: list[dict[str, str]] = []
332
+ current: dict[str, str] | None = None
333
+ for line in text.splitlines():
334
+ heading = line.startswith("### BC-")
335
+ if heading:
336
+ if current:
337
+ blocks.append(current)
338
+ identifier, _, title = line.removeprefix("### ").partition(":")
339
+ current = {"id": identifier.strip(), "title": title.strip()}
340
+ continue
341
+ if current is None or not line.startswith("- ") or ":" not in line:
342
+ continue
343
+ key, _, value = line[2:].partition(":")
344
+ current[key.strip().lower()] = value.strip()
345
+ if current:
346
+ blocks.append(current)
347
+ return blocks
348
+
349
+
350
+ def unresolved_bad_cases(path: Path) -> list[dict[str, str]]:
351
+ if not path.exists():
352
+ return []
353
+ resolved_statuses = {"resolved", "done", "superseded-by-route-change"}
354
+ cases = bad_case_blocks(path.read_text(encoding="utf-8"))
355
+ return [
356
+ case
357
+ for case in cases
358
+ if case.get("status", "").strip().lower() not in resolved_statuses
359
+ ]
360
+
361
+
362
+ def format_unresolved_bad_cases(cases: list[dict[str, str]], limit: int = 5) -> str:
363
+ if not cases:
364
+ return "none"
365
+ parts = []
366
+ for case in cases[:limit]:
367
+ status = case.get("status", "unknown") or "unknown"
368
+ title = case.get("title", "Untitled bad case")
369
+ phenomenon = case.get("phenomenon", "").strip()
370
+ summary = f"{title} ({status})"
371
+ if phenomenon:
372
+ summary += f" - {phenomenon}"
373
+ parts.append(summary)
374
+ if len(cases) > limit:
375
+ parts.append(f"{len(cases) - limit} more unresolved bad cases")
376
+ return "; ".join(parts)
377
+
378
+
379
+ def main() -> int:
380
+ event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
381
+ raw = read_stdin()
382
+ root, root_source = event_root(raw, Path.cwd())
383
+ context_dir = root / ".codex" / "context"
384
+ index_path = context_dir / "index.md"
385
+ roadmap_path = context_dir / "roadmap.md"
386
+ bad_cases_path = context_dir / "bad-cases.md"
387
+ text = prompt_text(raw)
388
+
389
+ if is_context_guard_skill_path(root):
390
+ hook_log(
391
+ "[context-guard] detected Context Guard skill directory as the apparent root; "
392
+ "skipping project context writes. Open the target Codex folder or pass an explicit local `--root` "
393
+ "when showing/updating a roadmap."
394
+ )
395
+ hook_log(f"[context-guard] apparent root source: {root_source}; apparent root: {root}")
396
+ return hook_response()
397
+
398
+ if event == "session-start":
399
+ created = init_context(root)
400
+ if created:
401
+ hook_log(f"[context-guard] initialized folder context: {context_dir}")
402
+ else:
403
+ hook_log(f"[context-guard] folder context ready: {context_dir}")
404
+ hook_log(f"[context-guard] project root: {root} ({root_source})")
405
+ hook_log("[context-guard] context location rule: save project context only under `<opened local Codex project root>/.codex/context/`.")
406
+ hook_log("[context-guard] use .codex/context/index.md for quick scan and .codex/context/roadmap.md for route nodes.")
407
+ return hook_response()
408
+
409
+ if event == "user-prompt-submit":
410
+ hints: list[str] = []
411
+ if looks_like_goal_mode(text):
412
+ hints.append("goal mode: align active goal with current context and record roadmap/bad-case checkpoints during long-running work")
413
+ if looks_like_remote_work(text):
414
+ hints.append("remote/SSH work: keep `.codex/context` in the local Codex workspace; record remote host/path as metadata and do not initialize roadmap context on the server unless explicitly requested")
415
+ if looks_like_test_creation(text):
416
+ hints.append("explicit test creation: start the user-visible response with `测试创建识别:...`, summarize the test target from state A to state B, and only create durable tests after the user's design is clear or confirmed")
417
+ if looks_like_explicit_branch(text):
418
+ hints.append("explicit branch task: create/select a branch task by running `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` before implementation; verify the roadmap node has Branch: and Parent:")
419
+ elif looks_like_route_drift(text):
420
+ hints.append("possible route drift: ask whether to create a branch before moving away from the current mainline architecture")
421
+ if looks_like_task_switch(text):
422
+ hints.append("possible task switch: park current context in .codex/context/index.md before switching")
423
+ if looks_like_bad_case(text):
424
+ hints.append("possible bad case: record/update .codex/context/bad-cases.md or task-local bad-cases.md")
425
+ if not hints:
426
+ hints.append("run Context Guard intake: continue current context or note no active context")
427
+ hook_log("[context-guard] " + "; ".join(hints))
428
+ hook_log(f"[context-guard] root source: {root_source}")
429
+ hook_log(f"[context-guard] project root: {root}")
430
+ hook_log(f"[context-guard] context folder: {context_dir}")
431
+ hook_log(f"[context-guard] context index: {index_path}")
432
+ hook_log(f"[context-guard] route map: {roadmap_path}")
433
+ return hook_response()
434
+
435
+ if event == "stop":
436
+ hook_log("[context-guard] run turn-end checkpoint before finalizing or updating a goal: update index, route map nodes, parked/resume tasks, and relevant bad-case/test-chain links.")
437
+ hook_log("[context-guard] COMPLETION RELIABILITY GATE: use existing user screenshots/logs/reproductions as red evidence when available; implement once the cause is clear, then run the smallest real post-fix check. Default budget is one primary check plus at most two highly relevant bad-case guards.")
438
+ hook_log("[context-guard] BAD-CASE GUARD GATE: newly checked resolved or recurred BC entries need Guard type, Red condition, Green condition, Expected failure reason, and a red-capable Guard / verification; run `context_guard.py validate-bad-cases` only after register/schema/renderer edits, or `--strict` when intentionally migrating/checking all resolved cases.")
439
+ hook_log("[context-guard] GUARD SELECTION GATE: do not run every historical guard and do not manufacture new red tests when credible evidence already exists. Select guards by changed files, feature area, route branch, tags, and original user-visible symptom; skip unrelated resolved cases.")
440
+ hook_log("[context-guard] TEST HUB GATE: before finalizing development work, prefer `context_guard.py dev-complete --root <project>` so the hub runs every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
441
+ hook_log("[context-guard] TASK-CASE GATE: when a workflow has multiple phases, prefer one relevant task case from `.codex/context/task-cases/` with phase/checkpoint logs over many isolated bug-level tests; report the failed phase/checkpoint if it breaks.")
442
+ hook_log("[context-guard] TEST BLOCKER GATE: if approved tests are blocked by credentials, external service outage, permission denial, hardware/resource limits, network, destructive-risk confirmation, or user-only judgment, stop and ask/warn the user with the exact blocker and evidence path.")
443
+ hook_log("[context-guard] TASK-CASE DESIGN GATE: before writing a new durable task-case script for a complex workflow, ask the user to confirm a short business-facing proposal: from what state to what state, main task, and major risk; keep technical details inside the task-case file, or keep it `proposed` if unavailable.")
444
+ hook_log("[context-guard] GOAL-MODE TEST GATE: in goal mode, use task cases as phase gates; log current phase progress and run the smallest approved path before claiming goal completion instead of silently creating broad new tests.")
445
+ hook_log("[context-guard] ROADMAP CHECKPOINT GATE: assess whether this turn deserves a roadmap node. Create one only for meaningful progress, a route decision, a fix, a branch/fork, a user-visible milestone, or stale hidden checkpoints; otherwise say no roadmap node was needed and why.")
446
+ hook_log("[context-guard] If a node is needed, run `context_guard.py checkpoint-roadmap-node --title <short title> --branch <Main or route> --level <major|checkpoint> --outcome <one-line progress> --next-step <next>` and include linked BC/test-chain notes when relevant.")
447
+ hook_log("[context-guard] ROADMAP MAINTENANCE GATE: run `context_guard.py validate-roadmap-maintenance` after route updates; do not let mainline/branch overview stay stale while important work is hidden as checkpoints.")
448
+ hook_log("[context-guard] If frontend/UI/HTML/CSS/layout/browser behavior changed, inspect with browser/screenshot or state the exact blocker; do not claim fixed without this evidence.")
449
+ hook_log("[context-guard] Branch task gate: if the user explicitly asked for a branch, ensure `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` has created the task folder, index current entry, and Branch/Parent roadmap node; if the work significantly drifts from the mainline architecture, ask whether to create a branch before finalizing.")
450
+ hook_log("[context-guard] final answer must include verification evidence and must not say done/fixed/passing unless the gate above was satisfied.")
451
+ hook_log(f"[context-guard] root source: {root_source}")
452
+ hook_log(f"[context-guard] project root: {root}")
453
+ hook_log(f"[context-guard] context folder: {context_dir}")
454
+ hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
455
+ open_cases = unresolved_bad_cases(bad_cases_path)
456
+ hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
457
+ hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
458
+ return hook_response()
459
+
460
+ hook_log("[context-guard] unknown hook event; use the context-guard skill if context changed.")
461
+ return hook_response()
462
+
463
+
464
+ if __name__ == "__main__":
465
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py}"
5
+
6
+ python3 - "$SCRIPT" <<'PY'
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ script = Path(sys.argv[1])
17
+ root = Path(tempfile.mkdtemp(prefix="context-guard-branch-test-"))
18
+
19
+ try:
20
+ subprocess.run(["python3", str(script), "init", "--root", str(root)], check=True, capture_output=True, text=True)
21
+ subprocess.run(["python3", str(script), "set-language", "--language", "zh", "--root", str(root)], check=True, capture_output=True, text=True)
22
+
23
+ ctx = root / ".codex" / "context"
24
+ (ctx / "index.md").write_text(
25
+ """# Context Index
26
+
27
+ ## Quick Scan
28
+
29
+ - Current: CTX-20260618-main
30
+ - Latest roadmap node: NODE-20260618-001
31
+ - Hot bad-case tags: none
32
+ - Resume candidate: none
33
+
34
+ ## Current
35
+
36
+ - ID: CTX-20260618-main
37
+ - Title: 主线任务
38
+ - State: current
39
+ - Folder: `.codex/context/tasks/CTX-20260618-main/`
40
+ - Last updated: 2026-06-18
41
+ - Summary: 正在推进主线任务。
42
+ - Next step: 继续主线。
43
+
44
+ ## Parked / Resume Candidates
45
+
46
+ None.
47
+
48
+ ## Archived
49
+
50
+ None.
51
+ """,
52
+ encoding="utf-8",
53
+ )
54
+ (ctx / "roadmap.md").write_text(
55
+ """# Context Roadmap
56
+
57
+ ## Nodes
58
+
59
+ ### NODE-20260618-001: 主线起点
60
+
61
+ - Date: 2026-06-18
62
+ - Status: done
63
+ - Level: major
64
+ - Branch: Main
65
+ - Parent: none
66
+ - Task: `CTX-20260618-main`
67
+ - Outcome: 主线已经建立。
68
+ - Decision / reason: 作为支线父节点。
69
+ - Avoid going back: 不要把支线写回主线。
70
+ - Next: 等待支线。
71
+ - Linked bad cases: none
72
+ - Test chain: none
73
+ """,
74
+ encoding="utf-8",
75
+ )
76
+
77
+ result = subprocess.run(
78
+ [
79
+ "python3",
80
+ str(script),
81
+ "create-branch-task",
82
+ "--root",
83
+ str(root),
84
+ "--title",
85
+ "后端状态机设计",
86
+ "--branch",
87
+ "后端状态机",
88
+ "--parent-node",
89
+ "NODE-20260618-001",
90
+ ],
91
+ check=True,
92
+ capture_output=True,
93
+ text=True,
94
+ )
95
+
96
+ output = result.stdout + result.stderr
97
+ assert "CTX-" in output, output
98
+ assert "NODE-" in output, output
99
+
100
+ index = (ctx / "index.md").read_text(encoding="utf-8")
101
+ roadmap = (ctx / "roadmap.md").read_text(encoding="utf-8")
102
+ exports = json.loads((ctx / "roadmap" / "roadmap.json").read_text(encoding="utf-8"))
103
+
104
+ assert "- Current: CTX-20260618-main" not in index, index
105
+ assert "后端状态机设计" in index, index
106
+ assert "主线任务" in index and "resume-candidate" in index, index
107
+ assert "- Branch: 后端状态机" in roadmap, roadmap
108
+ assert "- Parent: NODE-20260618-001" in roadmap, roadmap
109
+ assert "- Task: `CTX-" in roadmap, roadmap
110
+ assert any(route["branch"] == "后端状态机" for route in exports["routes"]), exports
111
+
112
+ task_dirs = list((ctx / "tasks").glob("CTX-*"))
113
+ assert any((path / "context.md").exists() and "后端状态机设计" in (path / "context.md").read_text(encoding="utf-8") for path in task_dirs), task_dirs
114
+ finally:
115
+ shutil.rmtree(root, ignore_errors=True)
116
+ PY
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py}"
5
+ ROOT="$(mktemp -d "${TMPDIR:-/tmp}/context-guard-marker-test-XXXXXX")"
6
+ trap 'rm -rf "$ROOT"' EXIT
7
+
8
+ python3 "$SCRIPT" init --root "$ROOT" >/dev/null
9
+ python3 "$SCRIPT" set-language --language zh --root "$ROOT" >/dev/null
10
+
11
+ CTX="$ROOT/.codex/context"
12
+ cat > "$CTX/roadmap.md" <<'MD'
13
+ # Context Roadmap
14
+
15
+ ## Nodes
16
+
17
+ ### NODE-20260618-001: 标记测试节点
18
+
19
+ - Date: 2026-06-18
20
+ - Status: done
21
+ - Level: major
22
+ - Branch: Main
23
+ - Parent: none
24
+ - Task: `CTX-20260618-marker`
25
+ - Outcome: 检查问题案例标题行标记布局。
26
+ - Decision / reason: 标记不能单独占一行。
27
+ - Avoid going back: 不要把状态点放在标题前面。
28
+ - Next: none
29
+ - Linked bad cases: BC-20260618-999
30
+ - Test chain: none
31
+ MD
32
+
33
+ cat > "$CTX/bad-cases.md" <<'MD'
34
+ # Bad Case Register
35
+
36
+ ## Active Cases
37
+
38
+ ### BC-20260618-999: 这是一个很长的问题案例标题用于触发换行但标记不能单独成行
39
+
40
+ - Status: resolved
41
+ - First observed: 2026-06-18
42
+ - Last checked: 2026-06-18
43
+ - Scope: roadmap marker layout
44
+ - Context task: `CTX-20260618-marker`
45
+ - Roadmap nodes: NODE-20260618-001
46
+ - Tags: #roadmap-ux
47
+ - Frequency: repeated-2
48
+ - Phenomenon: 状态点和频率点不应该单独一行。
49
+ - Trigger / reproduction: 生成 roadmap 并检查 badcase-head。
50
+ - Root cause: 标记位于标题前,窄列时容易变成单独一行。
51
+ - Fix method: 将标记移到标题右侧的 inline marker 容器。
52
+ - Guard / verification: 本脚本检查 HTML 结构。
53
+ - Reusable guard path: none
54
+ - Test chain: none
55
+ MD
56
+
57
+ python3 "$SCRIPT" show-roadmap --root "$ROOT" >/dev/null
58
+ HTML="$CTX/roadmap/roadmap.html"
59
+
60
+ grep -q 'class="badcase-markers"' "$HTML"
61
+ grep -q '.badcase-head { display: grid; grid-template-columns: minmax(0, 1fr) auto;' "$HTML"
62
+ if grep -q '<div class="badcase-head"><span class="status-dot' "$HTML"; then
63
+ echo "badcase marker still starts a separate title row" >&2
64
+ exit 1
65
+ fi
66
+ grep -q '<a class="detail-link" href="#case-1">' "$HTML"
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py}"
5
+ ROOT="$(mktemp -d "${TMPDIR:-/tmp}/context-guard-readable-map-XXXXXX")"
6
+ trap 'rm -rf "$ROOT"' EXIT
7
+
8
+ python3 "$SCRIPT" init --root "$ROOT" >/dev/null
9
+ python3 "$SCRIPT" set-language --language zh --root "$ROOT" >/dev/null
10
+
11
+ CTX="$ROOT/.codex/context"
12
+ cat > "$CTX/roadmap.md" <<'MD'
13
+ # Context Roadmap
14
+
15
+ ## Nodes
16
+
17
+ ### NODE-20260626-001: 主线起点
18
+
19
+ - Date: 2026-06-26
20
+ - Status: done
21
+ - Level: major
22
+ - Branch: Main
23
+ - Parent: none
24
+ - Outcome: 这是一段很长的主线摘要,过去会直接显示在多路线概览卡片里,让路线图看起来像卡片墙而不是路线骨架。
25
+ - Next: none
26
+ - Linked bad cases: none
27
+
28
+ ### NODE-20260626-002: 支线入口
29
+
30
+ - Date: 2026-06-26
31
+ - Status: done
32
+ - Level: major
33
+ - Branch: 可读性支线
34
+ - Parent: NODE-20260626-001
35
+ - Outcome: 这是一段很长的支线摘要,应该留在详情里,而不是占据概览卡片的主要视觉空间。
36
+ - Next: none
37
+ - Linked bad cases: none
38
+ MD
39
+
40
+ python3 "$SCRIPT" show-roadmap --root "$ROOT" >/dev/null
41
+ HTML="$CTX/roadmap/roadmap.html"
42
+
43
+ grep -q 'class="route-stack branch-map"' "$HTML"
44
+ grep -q '.track-grid.route-only {' "$HTML"
45
+ grep -q 'grid-auto-columns: minmax(180px, 230px);' "$HTML"
46
+ grep -q '.branch-map .summary {' "$HTML"
47
+ grep -q 'display: none;' "$HTML"
48
+ grep -q -- '-webkit-line-clamp: 3;' "$HTML"