@michelj/context-guard 0.4.0 → 0.4.1

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.
@@ -4,18 +4,15 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
- import contextlib
8
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
8
  import html
10
9
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
- import io
12
10
  import json
13
11
  import os
14
12
  import re
15
13
  import shutil
16
14
  import subprocess
17
15
  import sys
18
- import threading
19
16
  import webbrowser
20
17
  from datetime import datetime
21
18
  from pathlib import Path
@@ -3630,6 +3627,10 @@ def test_registry_path(ctx: Path) -> Path:
3630
3627
  return ctx / "test-hub" / "registry.json"
3631
3628
 
3632
3629
 
3630
+ def feature_chains_path(ctx: Path) -> Path:
3631
+ return ctx / "test-hub" / "feature-chains.json"
3632
+
3633
+
3633
3634
  def default_test_registry() -> dict[str, object]:
3634
3635
  return {
3635
3636
  "version": 1,
@@ -3673,6 +3674,206 @@ def next_test_id(ctx: Path, title: str) -> str:
3673
3674
  return f"TC-{today}-{(max(numbers) + 1 if numbers else 1):03d}-{normalize_test_slug(title)[:36]}"
3674
3675
 
3675
3676
 
3677
+ def default_feature_chain_registry() -> dict[str, object]:
3678
+ return {
3679
+ "version": 1,
3680
+ "description": "Feature-oriented test chains. One chain should cover a real workflow and multiple linked bad-case recurrence checks.",
3681
+ "chains": [],
3682
+ }
3683
+
3684
+
3685
+ def load_feature_chains(ctx: Path) -> dict[str, object]:
3686
+ path = feature_chains_path(ctx)
3687
+ if not path.exists():
3688
+ return default_feature_chain_registry()
3689
+ try:
3690
+ data = json.loads(path.read_text(encoding="utf-8"))
3691
+ except json.JSONDecodeError:
3692
+ print(f"[context-guard] invalid feature-chain JSON: {path}", file=sys.stderr)
3693
+ return default_feature_chain_registry()
3694
+ if not isinstance(data, dict):
3695
+ return default_feature_chain_registry()
3696
+ chains = data.get("chains")
3697
+ if not isinstance(chains, list):
3698
+ data["chains"] = []
3699
+ data.setdefault("version", 1)
3700
+ data.setdefault("description", default_feature_chain_registry()["description"])
3701
+ return data
3702
+
3703
+
3704
+ def write_feature_chains(ctx: Path, registry: dict[str, object]) -> Path:
3705
+ path = feature_chains_path(ctx)
3706
+ path.parent.mkdir(parents=True, exist_ok=True)
3707
+ path.write_text(json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
3708
+ return path
3709
+
3710
+
3711
+ def next_feature_chain_id(ctx: Path) -> str:
3712
+ today = datetime.now().strftime("%Y%m%d")
3713
+ registry = load_feature_chains(ctx)
3714
+ existing = " ".join(
3715
+ str(chain.get("id", "")) for chain in registry.get("chains", []) if isinstance(chain, dict)
3716
+ )
3717
+ numbers = [int(match.group(1)) for match in re.finditer(rf"FC-{today}-(\d+)", existing)]
3718
+ return f"FC-{today}-{(max(numbers) + 1 if numbers else 1):03d}"
3719
+
3720
+
3721
+ def feature_chain_find(
3722
+ ctx: Path, chain_id: str
3723
+ ) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
3724
+ registry = load_feature_chains(ctx)
3725
+ chains = registry.setdefault("chains", [])
3726
+ if not isinstance(chains, list):
3727
+ chains = []
3728
+ registry["chains"] = chains
3729
+ matches = [item for item in chains if isinstance(item, dict) and str(item.get("id", "")) == chain_id]
3730
+ if not matches:
3731
+ raise ValueError(f"feature chain not found: {chain_id}")
3732
+ return matches[0], chains, registry
3733
+
3734
+
3735
+ def feature_chain_add(
3736
+ root: Path,
3737
+ title: str,
3738
+ entry: str,
3739
+ exit_check: str,
3740
+ command_text: str = "",
3741
+ run_policy: str = RUN_ALWAYS_POLICY,
3742
+ status: str = "proposed",
3743
+ timeout_seconds: int = 300,
3744
+ artifact_policy: str = "cleanup-on-pass",
3745
+ resource: str = "local",
3746
+ ) -> Path:
3747
+ init_context(root)
3748
+ ctx = context_dir(root)
3749
+ if not title.strip():
3750
+ raise ValueError("feature-chain-add requires --title")
3751
+ if not entry.strip():
3752
+ raise ValueError("feature-chain-add requires --entry")
3753
+ if not exit_check.strip():
3754
+ raise ValueError("feature-chain-add requires --exit-check")
3755
+ registry = load_feature_chains(ctx)
3756
+ chains = registry.setdefault("chains", [])
3757
+ if not isinstance(chains, list):
3758
+ chains = []
3759
+ registry["chains"] = chains
3760
+ chain_id = next_feature_chain_id(ctx)
3761
+ now = datetime.now().isoformat(timespec="seconds")
3762
+ chains.append(
3763
+ {
3764
+ "id": chain_id,
3765
+ "title": title.strip(),
3766
+ "status": status.strip() or "proposed",
3767
+ "run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
3768
+ "entry": entry.strip(),
3769
+ "exit_check": exit_check.strip(),
3770
+ "type": "command" if command_text.strip() else "manual",
3771
+ "command": command_text.strip(),
3772
+ "cwd": ".",
3773
+ "resource": resource.strip() or "local",
3774
+ "timeout_seconds": int(timeout_seconds),
3775
+ "artifact_policy": artifact_policy.strip() or "cleanup-on-pass",
3776
+ "blocker_keywords": BLOCKER_PATTERNS,
3777
+ "nodes": [],
3778
+ "created_at": now,
3779
+ "updated_at": now,
3780
+ "source": "feature-chain",
3781
+ }
3782
+ )
3783
+ path = write_feature_chains(ctx, registry)
3784
+ print(f"[context-guard] feature chain: {chain_id}")
3785
+ print(f"[context-guard] registry: {path}")
3786
+ return path
3787
+
3788
+
3789
+ def feature_chain_attach_bc(
3790
+ root: Path,
3791
+ chain_id: str,
3792
+ node_title: str,
3793
+ bad_case: str,
3794
+ check: str = "",
3795
+ ) -> Path:
3796
+ init_context(root)
3797
+ ctx = context_dir(root)
3798
+ if not chain_id.strip():
3799
+ raise ValueError("feature-chain-attach-bc requires --chain-id")
3800
+ if not node_title.strip():
3801
+ raise ValueError("feature-chain-attach-bc requires --node-title")
3802
+ if not bad_case.strip():
3803
+ raise ValueError("feature-chain-attach-bc requires --bad-case")
3804
+ chain, _chains, registry = feature_chain_find(ctx, chain_id.strip())
3805
+ nodes = chain.setdefault("nodes", [])
3806
+ if not isinstance(nodes, list):
3807
+ nodes = []
3808
+ chain["nodes"] = nodes
3809
+ node_slug = normalize_test_slug(node_title)
3810
+ node: dict[str, object] | None = None
3811
+ for item in nodes:
3812
+ if not isinstance(item, dict):
3813
+ continue
3814
+ if str(item.get("title", "")).strip() == node_title.strip() or str(item.get("id", "")) == node_slug:
3815
+ node = item
3816
+ break
3817
+ if node is None:
3818
+ node = {
3819
+ "id": node_slug,
3820
+ "title": node_title.strip(),
3821
+ "checks": [],
3822
+ "bad_cases": [],
3823
+ "created_at": datetime.now().isoformat(timespec="seconds"),
3824
+ }
3825
+ nodes.append(node)
3826
+ bad_cases = node.setdefault("bad_cases", [])
3827
+ if not isinstance(bad_cases, list):
3828
+ bad_cases = []
3829
+ node["bad_cases"] = bad_cases
3830
+ if bad_case.strip() not in [str(item) for item in bad_cases]:
3831
+ bad_cases.append(bad_case.strip())
3832
+ checks = node.setdefault("checks", [])
3833
+ if not isinstance(checks, list):
3834
+ checks = []
3835
+ node["checks"] = checks
3836
+ if check.strip() and check.strip() not in [str(item) for item in checks]:
3837
+ checks.append(check.strip())
3838
+ now = datetime.now().isoformat(timespec="seconds")
3839
+ node["updated_at"] = now
3840
+ chain["updated_at"] = now
3841
+ path = write_feature_chains(ctx, registry)
3842
+ print(f"[context-guard] feature chain updated: {chain_id.strip()}")
3843
+ print(f"[context-guard] node: {node_title.strip()}")
3844
+ print(f"[context-guard] bad case: {bad_case.strip()}")
3845
+ return path
3846
+
3847
+
3848
+ def feature_chain_list(root: Path) -> int:
3849
+ init_context(root)
3850
+ ctx = context_dir(root)
3851
+ registry = load_feature_chains(ctx)
3852
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
3853
+ if not chains:
3854
+ print("[context-guard] feature chains: none.")
3855
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
3856
+ return 0
3857
+ print("[context-guard] feature chains:")
3858
+ for chain in chains:
3859
+ chain_id = str(chain.get("id", "unknown"))
3860
+ title = str(chain.get("title", "untitled"))
3861
+ status = str(chain.get("status", "unknown"))
3862
+ run_policy = str(chain.get("run_policy", "unset"))
3863
+ nodes = [item for item in chain.get("nodes", []) if isinstance(item, dict)]
3864
+ covered = sorted(
3865
+ {
3866
+ str(bc)
3867
+ for node in nodes
3868
+ for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
3869
+ }
3870
+ )
3871
+ suffix = f" | covers {', '.join(covered)}" if covered else ""
3872
+ print(f"- {chain_id} | {status} | {run_policy} | {title} | {len(nodes)} node(s){suffix}")
3873
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
3874
+ return 0
3875
+
3876
+
3676
3877
  def test_hub_add(
3677
3878
  root: Path,
3678
3879
  title: str,
@@ -3740,7 +3941,8 @@ def test_hub_list(root: Path) -> int:
3740
3941
  ctx = context_dir(root)
3741
3942
  registry_tests = test_hub_registry_tests(ctx)
3742
3943
  task_case_tests = discover_task_case_tests(ctx)
3743
- if not registry_tests and not task_case_tests:
3944
+ feature_chain_tests = approved_feature_chain_tests(ctx)
3945
+ if not registry_tests and not task_case_tests and not feature_chain_tests:
3744
3946
  print("[context-guard] test hub: no registered tests.")
3745
3947
  print(f"[context-guard] registry: {test_registry_path(ctx)}")
3746
3948
  return 0
@@ -3761,6 +3963,17 @@ def test_hub_list(root: Path) -> int:
3761
3963
  title = str(test.get("title", "untitled"))
3762
3964
  source = str(test.get("source", "task-cases"))
3763
3965
  print(f"- {test_id} | {test.get('status')} | {test.get('run_policy')} | {title} | {source}")
3966
+ if feature_chain_tests:
3967
+ print("[context-guard] approved feature-chain tests:")
3968
+ for test in feature_chain_tests:
3969
+ test_id = str(test.get("id", "unknown"))
3970
+ title = str(test.get("title", "untitled"))
3971
+ source = str(test.get("source", "feature-chain"))
3972
+ covered = test.get("covered_bad_cases", [])
3973
+ covered_suffix = ""
3974
+ if isinstance(covered, list) and covered:
3975
+ covered_suffix = f" | covers {', '.join(str(item) for item in covered)}"
3976
+ print(f"- {test_id} | {test.get('status')} | {test.get('run_policy')} | {title} | {source}{covered_suffix}")
3764
3977
  print(f"[context-guard] registry: {test_registry_path(ctx)}")
3765
3978
  return 0
3766
3979
 
@@ -3781,8 +3994,9 @@ def test_hub_state(root: Path) -> dict[str, object]:
3781
3994
  ctx = context_dir(root)
3782
3995
  registry_tests = test_hub_registry_tests(ctx)
3783
3996
  task_case_tests = discover_task_case_tests(ctx)
3997
+ feature_chain_tests = approved_feature_chain_tests(ctx)
3784
3998
  tests: list[dict[str, object]] = []
3785
- for source, items in (("registry", registry_tests), ("task-case", task_case_tests)):
3999
+ for source, items in (("registry", registry_tests), ("task-case", task_case_tests), ("feature-chain", feature_chain_tests)):
3786
4000
  for item in items:
3787
4001
  status = str(item.get("status", "unknown")).strip() or "unknown"
3788
4002
  run_policy = str(item.get("run_policy", item.get("run policy", "unset"))).strip() or "unset"
@@ -3954,20 +4168,6 @@ def render_test_hub_html(root: Path) -> str:
3954
4168
  p {{ margin: 0; }}
3955
4169
  .subtle, .muted {{ color: var(--muted); }}
3956
4170
  .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
4171
  .hub-board {{
3972
4172
  background: var(--panel);
3973
4173
  border: 1px solid var(--line);
@@ -3989,7 +4189,7 @@ def render_test_hub_html(root: Path) -> str:
3989
4189
  gap: 10px;
3990
4190
  margin-bottom: 14px;
3991
4191
  }}
3992
- .metric, .test-card, .empty, .log {{
4192
+ .metric, .test-card, .empty {{
3993
4193
  background: var(--card);
3994
4194
  border: 1px solid var(--line);
3995
4195
  border-radius: var(--radius);
@@ -4051,8 +4251,6 @@ def render_test_hub_html(root: Path) -> str:
4051
4251
  font-size: 12px;
4052
4252
  }}
4053
4253
  .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
4254
  @media (max-width: 760px) {{
4057
4255
  header, .shell {{ padding-left: 16px; padding-right: 16px; }}
4058
4256
  .header-row, .card-head, .board-head {{ flex-direction: column; align-items: flex-start; }}
@@ -4065,11 +4263,10 @@ def render_test_hub_html(root: Path) -> str:
4065
4263
  <div class="header-row">
4066
4264
  <div>
4067
4265
  <h1>测试中台</h1>
4068
- <p class="subtle">查看用户已确认的测试;localhost 控制台支持一键运行。</p>
4266
+ <p class="subtle">查看用户已确认的测试;开发结束时由 Context Guard 自动运行。</p>
4069
4267
  </div>
4070
4268
  <div class="actions">
4071
- <button id="run-all" type="button">运行所有测试</button>
4072
- <p id="mode" class="subtle"></p>
4269
+ <p class="subtle">只读状态页</p>
4073
4270
  </div>
4074
4271
  </div>
4075
4272
  </header>
@@ -4090,39 +4287,10 @@ def render_test_hub_html(root: Path) -> str:
4090
4287
  <section class="grid" aria-label="测试列表">
4091
4288
  {''.join(rows)}
4092
4289
  </section>
4093
- <pre id="log" class="log"></pre>
4094
4290
  </section>
4095
4291
  </main>
4096
4292
  <script>
4097
4293
  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
4294
  </script>
4127
4295
  </body>
4128
4296
  </html>
@@ -4143,7 +4311,6 @@ def show_test_hub(root: Path, open_browser: bool = False) -> Path:
4143
4311
 
4144
4312
  def serve_test_hub(root: Path, host: str, port: int, jobs: int, open_browser: bool = False) -> int:
4145
4313
  init_context(root)
4146
- run_lock = threading.Lock()
4147
4314
 
4148
4315
  class TestHubHandler(BaseHTTPRequestHandler):
4149
4316
  def log_message(self, format: str, *args: object) -> None:
@@ -4173,29 +4340,7 @@ def serve_test_hub(root: Path, host: str, port: int, jobs: int, open_browser: bo
4173
4340
  self.send_json(404, {"error": "not found"})
4174
4341
 
4175
4342
  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()
4343
+ self.send_json(405, {"error": "test hub page is read-only; tests run from the Stop hook or `dev-complete`."})
4199
4344
 
4200
4345
  server = ThreadingHTTPServer((host, port), TestHubHandler)
4201
4346
  actual_host, actual_port = server.server_address[:2]
@@ -4289,6 +4434,43 @@ def discover_task_case_tests(ctx: Path) -> list[dict[str, object]]:
4289
4434
  return tests
4290
4435
 
4291
4436
 
4437
+ def approved_feature_chain_tests(ctx: Path) -> list[dict[str, object]]:
4438
+ registry = load_feature_chains(ctx)
4439
+ tests: list[dict[str, object]] = []
4440
+ for item in registry.get("chains", []):
4441
+ if not isinstance(item, dict):
4442
+ continue
4443
+ status = str(item.get("status", "")).strip().lower()
4444
+ run_policy = str(item.get("run_policy", item.get("run policy", ""))).strip()
4445
+ if status not in APPROVED_TEST_STATUSES or run_policy != RUN_ALWAYS_POLICY:
4446
+ continue
4447
+ command = str(item.get("command", "")).strip()
4448
+ nodes = [node for node in item.get("nodes", []) if isinstance(node, dict)]
4449
+ covered_bad_cases = sorted(
4450
+ {
4451
+ str(bc)
4452
+ for node in nodes
4453
+ for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
4454
+ if str(bc).strip()
4455
+ }
4456
+ )
4457
+ normalized = dict(item)
4458
+ normalized["id"] = str(item.get("id") or item.get("title") or "feature-chain")
4459
+ normalized["title"] = str(item.get("title") or normalized["id"])
4460
+ normalized["run_policy"] = run_policy
4461
+ normalized["type"] = "command" if command else "manual"
4462
+ normalized["command"] = command
4463
+ normalized.setdefault("cwd", ".")
4464
+ normalized.setdefault("timeout_seconds", 300)
4465
+ normalized.setdefault("artifact_policy", "cleanup-on-pass")
4466
+ normalized.setdefault("resource", "local")
4467
+ normalized.setdefault("blocker_keywords", BLOCKER_PATTERNS)
4468
+ normalized["source"] = "feature-chain"
4469
+ normalized["covered_bad_cases"] = covered_bad_cases
4470
+ tests.append(normalized)
4471
+ return tests
4472
+
4473
+
4292
4474
  def approved_registry_tests(ctx: Path) -> list[dict[str, object]]:
4293
4475
  registry = load_test_registry(ctx)
4294
4476
  tests: list[dict[str, object]] = []
@@ -4311,7 +4493,7 @@ def approved_registry_tests(ctx: Path) -> list[dict[str, object]]:
4311
4493
 
4312
4494
  def approved_dev_completion_tests(ctx: Path) -> list[dict[str, object]]:
4313
4495
  by_id: dict[str, dict[str, object]] = {}
4314
- for test in approved_registry_tests(ctx) + discover_task_case_tests(ctx):
4496
+ for test in approved_registry_tests(ctx) + discover_task_case_tests(ctx) + approved_feature_chain_tests(ctx):
4315
4497
  test_id = str(test.get("id") or test.get("title") or "unnamed")
4316
4498
  by_id[test_id] = test
4317
4499
  return list(by_id.values())
@@ -4502,7 +4684,7 @@ def create_branch_task(root: Path, title: str, branch: str, parent_node: str = "
4502
4684
 
4503
4685
  def main() -> int:
4504
4686
  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"])
4687
+ 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", "feature-chain-add", "feature-chain-attach-bc", "feature-chain-list", "show-test-hub", "serve-test-hub", "dev-complete"])
4506
4688
  parser.add_argument("--format", choices=["html", "md"], default="html")
4507
4689
  parser.add_argument("--language", default=None, help="Folder-scoped language for future context records.")
4508
4690
  parser.add_argument("--title", default=None, help="Title for a branch task or roadmap checkpoint.")
@@ -4514,6 +4696,12 @@ def main() -> int:
4514
4696
  parser.add_argument("--timeout-seconds", type=int, default=300, help="Timeout for a registered test command.")
4515
4697
  parser.add_argument("--artifact-policy", default="cleanup-on-pass", help="Artifact policy for a registered test command.")
4516
4698
  parser.add_argument("--resource", default="local", help="Resource/node label for a registered test command.")
4699
+ parser.add_argument("--entry", default="", help="Human-readable entry point for a feature-chain test.")
4700
+ parser.add_argument("--exit-check", default="", help="Human-readable success condition for a feature-chain test.")
4701
+ parser.add_argument("--chain-id", default="", help="Feature chain ID for feature-chain management commands.")
4702
+ parser.add_argument("--node-title", default="", help="Feature-chain node title for attaching bad-case coverage.")
4703
+ parser.add_argument("--bad-case", default="", help="Bad-case ID or title covered by a feature-chain node.")
4704
+ parser.add_argument("--check", default="", help="Checkpoint text for a feature-chain node.")
4517
4705
  parser.add_argument("--jobs", type=int, default=1, help="Parallel test workers for dev-complete.")
4518
4706
  parser.add_argument("--host", default="127.0.0.1", help="Host for serve-test-hub.")
4519
4707
  parser.add_argument("--port", type=int, default=8772, help="Port for serve-test-hub.")
@@ -4604,6 +4792,43 @@ def main() -> int:
4604
4792
  resource=args.resource,
4605
4793
  )
4606
4794
  return 0
4795
+ if args.command == "feature-chain-add":
4796
+ if not args.title:
4797
+ parser.error("feature-chain-add requires --title")
4798
+ if not args.entry:
4799
+ parser.error("feature-chain-add requires --entry")
4800
+ if not args.exit_check:
4801
+ parser.error("feature-chain-add requires --exit-check")
4802
+ feature_chain_add(
4803
+ root,
4804
+ title=args.title,
4805
+ entry=args.entry,
4806
+ exit_check=args.exit_check,
4807
+ command_text=args.command_text,
4808
+ run_policy=args.run_policy,
4809
+ status=args.test_status,
4810
+ timeout_seconds=args.timeout_seconds,
4811
+ artifact_policy=args.artifact_policy,
4812
+ resource=args.resource,
4813
+ )
4814
+ return 0
4815
+ if args.command == "feature-chain-attach-bc":
4816
+ if not args.chain_id:
4817
+ parser.error("feature-chain-attach-bc requires --chain-id")
4818
+ if not args.node_title:
4819
+ parser.error("feature-chain-attach-bc requires --node-title")
4820
+ if not args.bad_case:
4821
+ parser.error("feature-chain-attach-bc requires --bad-case")
4822
+ feature_chain_attach_bc(
4823
+ root,
4824
+ chain_id=args.chain_id,
4825
+ node_title=args.node_title,
4826
+ bad_case=args.bad_case,
4827
+ check=args.check,
4828
+ )
4829
+ return 0
4830
+ if args.command == "feature-chain-list":
4831
+ return feature_chain_list(root)
4607
4832
  if args.command == "test-hub-list":
4608
4833
  return test_hub_list(root)
4609
4834
  if args.command == "test-hub-enable":
@@ -14,7 +14,7 @@ import subprocess
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
- from context_guard import context_dir, init_context
17
+ from context_guard import approved_dev_completion_tests, context_dir, init_context
18
18
 
19
19
 
20
20
  SKILL_ROOT = Path(__file__).resolve().parents[1]
@@ -147,6 +147,41 @@ def hook_response(**payload: object) -> int:
147
147
  return 0
148
148
 
149
149
 
150
+ def run_test_hub_completion(root: Path) -> tuple[int, str]:
151
+ ctx = context_dir(root)
152
+ tests = approved_dev_completion_tests(ctx)
153
+ if not tests:
154
+ return 0, "[context-guard] test hub: no approved every-dev-completion tests."
155
+
156
+ hook_log(f"[context-guard] test hub: running {len(tests)} approved every-dev-completion test(s).")
157
+ completed = subprocess.run(
158
+ [
159
+ sys.executable,
160
+ str(SKILL_ROOT / "scripts" / "context_guard.py"),
161
+ "dev-complete",
162
+ "--root",
163
+ str(root),
164
+ ],
165
+ text=True,
166
+ capture_output=True,
167
+ timeout=900,
168
+ )
169
+ output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part)
170
+ return completed.returncode, output
171
+
172
+
173
+ def completion_test_summary(output: str, code: int) -> str:
174
+ if "no approved every-dev-completion tests" in output:
175
+ return "no approved every-dev-completion tests"
176
+ for line in output.splitlines():
177
+ if "[context-guard] test hub:" in line and " passed," in line and " failed," in line and " blocked" in line:
178
+ summary = line.split("test hub:", 1)[1].strip().rstrip(".")
179
+ if code == 0:
180
+ return f"all approved tests passed ({summary})"
181
+ return f"approved tests are not all passing ({summary})"
182
+ return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
183
+
184
+
150
185
  def prompt_text(raw: str) -> str:
151
186
  if not raw.strip():
152
187
  return ""
@@ -437,7 +472,7 @@ def main() -> int:
437
472
  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
473
  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
474
  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.")
475
+ hook_log("[context-guard] TEST HUB GATE: Stop hook runs `context_guard.py dev-complete --root <project>` so the hub executes 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
476
  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
477
  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
478
  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.")
@@ -452,6 +487,28 @@ def main() -> int:
452
487
  hook_log(f"[context-guard] project root: {root}")
453
488
  hook_log(f"[context-guard] context folder: {context_dir}")
454
489
  hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
490
+ try:
491
+ test_code, test_output = run_test_hub_completion(root)
492
+ for line in test_output.splitlines():
493
+ hook_log(line)
494
+ hook_log(
495
+ "[context-guard] final answer must include Test Hub summary: "
496
+ + completion_test_summary(test_output, test_code)
497
+ )
498
+ if test_code != 0:
499
+ reason = (
500
+ "Context Guard Test Hub found failed or blocked approved tests. "
501
+ "Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
502
+ "then fix or report the blocker before finalizing."
503
+ )
504
+ hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
505
+ return hook_response(decision="block", reason=reason)
506
+ except subprocess.TimeoutExpired:
507
+ reason = "Context Guard Test Hub timed out after 900s; report this blocker and evidence before finalizing."
508
+ hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
509
+ return hook_response(decision="block", reason=reason)
510
+ except Exception as exc:
511
+ hook_log(f"[context-guard] test hub hook warning: {exc}")
455
512
  open_cases = unresolved_bad_cases(bad_cases_path)
456
513
  hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
457
514
  hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
@@ -0,0 +1,45 @@
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-feature-chain-XXXXXX")"
6
+ trap 'rm -rf "$ROOT"' EXIT
7
+
8
+ python3 "$SCRIPT" init --root "$ROOT" >/dev/null
9
+
10
+ ADD_OUTPUT="$(python3 "$SCRIPT" feature-chain-add \
11
+ --root "$ROOT" \
12
+ --title "GPU 监控按钮" \
13
+ --entry "点击 GPU 监控按钮" \
14
+ --exit-check "打开包含有效 grafana_url 的监控页" \
15
+ --command-text "printf 'feature-chain-ok\n'" \
16
+ --test-status approved)"
17
+
18
+ CHAIN_ID="$(printf '%s\n' "$ADD_OUTPUT" | awk '/feature chain:/ {print $NF}')"
19
+ test -n "$CHAIN_ID"
20
+
21
+ python3 "$SCRIPT" feature-chain-attach-bc \
22
+ --root "$ROOT" \
23
+ --chain-id "$CHAIN_ID" \
24
+ --node-title "后端返回监控 URL" \
25
+ --bad-case "BC-20260702-000" \
26
+ --check "grafana_url 不为空,前端不会卡住" >/dev/null
27
+
28
+ python3 "$SCRIPT" feature-chain-list --root "$ROOT" | grep -q "BC-20260702-000"
29
+
30
+ python3 - "$ROOT/.codex/context/test-hub/feature-chains.json" "$CHAIN_ID" <<'PY'
31
+ from pathlib import Path
32
+ import json
33
+ import sys
34
+
35
+ data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
36
+ chain = next(item for item in data["chains"] if item["id"] == sys.argv[2])
37
+ assert chain["entry"] == "点击 GPU 监控按钮"
38
+ assert chain["exit_check"] == "打开包含有效 grafana_url 的监控页"
39
+ assert chain["nodes"][0]["bad_cases"] == ["BC-20260702-000"]
40
+ assert "grafana_url 不为空" in chain["nodes"][0]["checks"][0]
41
+ PY
42
+
43
+ RUN_OUTPUT="$(python3 "$SCRIPT" dev-complete --root "$ROOT")"
44
+ printf '%s\n' "$RUN_OUTPUT" | grep -q "1 passed, 0 failed, 0 blocked"
45
+ printf '%s\n' "$RUN_OUTPUT" | grep -q "success artifacts cleaned"