@christang/keel 5.1.2 → 5.2.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.
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.1.2"
41
- PROTOCOL_VERSION = "5.1.2"
40
+ PACKAGE_VERSION = "5.2.1"
41
+ PROTOCOL_VERSION = "5.2.1"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
@@ -188,9 +188,9 @@ SKILL_DOC_REQUIREMENTS = [
188
188
  "one material decision at a time",
189
189
  "recommended answer",
190
190
  "Silence does not",
191
- "references/web.md",
192
- "references/hardware.md",
193
- "references/hardware-dsl.md",
191
+ "keel/lenses/",
192
+ "Applies when",
193
+ "keel lenses add",
194
194
  "no separate alignment ledger",
195
195
  ],
196
196
  },
@@ -220,31 +220,34 @@ SKILL_DOC_REQUIREMENTS = [
220
220
  ],
221
221
  },
222
222
  {
223
- # Structure only ("Material risk surface", "Durable placement"
224
- # section headers); the expectation-alignment-skill scenario owns
225
- # domain-keyword scoping across these three references, so the
226
- # per-domain keywords are not duplicated here.
227
- "name": "alignment web reference",
228
- "path": "src/skills/keel-align-expectations/references/web.md",
223
+ # Structure only ("Applies when" self-describing header, "Material risk
224
+ # surface", "Execution and review checks"); the expectation-alignment-skill
225
+ # scenario owns domain-keyword scoping across these three lens templates,
226
+ # so the per-domain keywords are not duplicated here.
227
+ "name": "web lens template",
228
+ "path": "assets/lenses/web.md",
229
229
  "required": [
230
+ "Applies when",
230
231
  "Material risk surface",
231
- "Durable placement",
232
+ "Execution and review checks",
232
233
  ],
233
234
  },
234
235
  {
235
- "name": "alignment hardware reference",
236
- "path": "src/skills/keel-align-expectations/references/hardware.md",
236
+ "name": "hardware lens template",
237
+ "path": "assets/lenses/hardware.md",
237
238
  "required": [
239
+ "Applies when",
238
240
  "Material risk surface",
239
- "Durable placement",
241
+ "Execution and review checks",
240
242
  ],
241
243
  },
242
244
  {
243
- "name": "alignment hardware-dsl reference",
244
- "path": "src/skills/keel-align-expectations/references/hardware-dsl.md",
245
+ "name": "hardware-dsl lens template",
246
+ "path": "assets/lenses/hardware-dsl.md",
245
247
  "required": [
248
+ "Applies when",
246
249
  "Material risk surface",
247
- "Durable placement",
250
+ "Execution and review checks",
248
251
  ],
249
252
  },
250
253
  ]
@@ -394,6 +397,7 @@ def validate_npm_package(errors: list[str]) -> None:
394
397
  "--doctor",
395
398
  "capabilities",
396
399
  "gate",
400
+ "lenses",
397
401
  "--profile",
398
402
  "--version",
399
403
  "--help",
@@ -640,6 +644,9 @@ def validate_openspec_schema(errors: list[str]) -> None:
640
644
  "provenance and license",
641
645
  "positive and negative trigger cases",
642
646
  "real-task evidence",
647
+ "one of pass, passed, complete, completed, ok, or done.",
648
+ "## Expectation Coverage",
649
+ "carry a durable owner",
643
650
  ):
644
651
  if required not in schema:
645
652
  errors.append(f"OpenSpec schema.yaml missing required language: {required}")
@@ -655,12 +662,15 @@ def validate_openspec_schema(errors: list[str]) -> None:
655
662
  "Strategy:",
656
663
  "M1:",
657
664
  "Evidence:",
658
- "Contract: pending task-start capsule and fingerprint",
665
+ "- Contract: pending",
659
666
  "Review:",
660
667
  "Status: pending",
668
+ "Status: one of pass, passed, complete, completed, ok, done",
661
669
  "Blocker: none",
662
670
  "Mode: diagnose-only",
663
671
  "Requires modifying files outside Touch.",
672
+ "## Expectation Coverage",
673
+ "Discard reason:",
664
674
  ):
665
675
  if required not in tasks_template:
666
676
  errors.append(
@@ -1540,7 +1550,7 @@ def validate_expectation_completion_gates_scenario() -> int:
1540
1550
  "- Acceptance check:",
1541
1551
  "- Scope check:",
1542
1552
  "- Findings:",
1543
- "discard rationale",
1553
+ "Discard rationale:",
1544
1554
  ]
1545
1555
  handoff_skill_snippets = [
1546
1556
  "keel-handoff/v1",
@@ -1606,13 +1616,13 @@ def validate_authoring_continuity_scenario() -> int:
1606
1616
  "keel-align-expectations",
1607
1617
  "risk-triggered deep path",
1608
1618
  "hidden-knowledge risk",
1609
- "domain references",
1619
+ "domain lenses",
1610
1620
  "Missing authority returns to OpenSpec authoring",
1611
1621
  ]
1612
1622
  schema_snippets = [
1613
1623
  "risk-triggered deep",
1614
1624
  "hidden-knowledge assumptions",
1615
- "domain reference",
1625
+ "domain lens",
1616
1626
  ]
1617
1627
  design_template_snippets = [
1618
1628
  "Hidden Knowledge / Assumptions",
@@ -1621,7 +1631,7 @@ def validate_authoring_continuity_scenario() -> int:
1621
1631
  ]
1622
1632
  task_template_snippets = [
1623
1633
  "hidden-knowledge assumption",
1624
- "domain profile",
1634
+ "domain lens",
1625
1635
  ]
1626
1636
 
1627
1637
  checks: list[tuple[str, Path, list[str]]] = [
@@ -1740,24 +1750,23 @@ def validate_authoring_continuity_scenario() -> int:
1740
1750
  return 0
1741
1751
 
1742
1752
 
1743
- def validate_domain_profiles_scenario() -> int:
1753
+ def validate_domain_lenses_scenario() -> int:
1744
1754
  plugin_skills_root = ROOT / PLUGIN_ROOT / "skills"
1745
- for expected in (
1746
- "keel-align-expectations/SKILL.md",
1747
- "keel-align-expectations/references/web.md",
1748
- "keel-align-expectations/references/hardware.md",
1749
- "keel-align-expectations/references/hardware-dsl.md",
1750
- ):
1751
- if not (plugin_skills_root / expected).is_file():
1755
+ if not (plugin_skills_root / "keel-align-expectations/SKILL.md").is_file():
1756
+ report("domain-lenses scenario plugin misses the alignment skill")
1757
+ return 1
1758
+ lenses_root = ROOT / "assets/lenses"
1759
+ for template in ("web.md", "hardware.md", "hardware-dsl.md"):
1760
+ if not (lenses_root / template).is_file():
1752
1761
  report(
1753
- "domain-profiles scenario plugin misses the packaged alignment "
1754
- f"reference set: {expected}"
1762
+ "domain-lenses scenario misses the shipped lens template: "
1763
+ f"{template}"
1755
1764
  )
1756
1765
  return 1
1757
1766
  for legacy_skill in LEGACY_PROFILE_SKILLS:
1758
1767
  if (plugin_skills_root / legacy_skill).exists():
1759
1768
  report(
1760
- f"domain-profiles scenario plugin packages a legacy profile: {legacy_skill}"
1769
+ f"domain-lenses scenario plugin packages a legacy profile: {legacy_skill}"
1761
1770
  )
1762
1771
  return 1
1763
1772
 
@@ -1767,20 +1776,20 @@ def validate_domain_profiles_scenario() -> int:
1767
1776
  repo.mkdir()
1768
1777
  install = run_keel(repo, "--install", "--target", "codex")
1769
1778
  if install.returncode != 0:
1770
- report("domain-profiles scenario default install failed:")
1779
+ report("domain-lenses scenario default install failed:")
1771
1780
  report((install.stderr or install.stdout).strip())
1772
1781
  return 1
1773
1782
  if (repo / TARGET_SKILL_ROOTS["codex"]).exists():
1774
1783
  report(
1775
- "domain-profiles scenario thin install copied Keel skill trees; "
1784
+ "domain-lenses scenario thin install copied Keel skill trees; "
1776
1785
  "skills are plugin-owned in v4."
1777
1786
  )
1778
1787
  return 1
1779
1788
 
1780
1789
  rejected = run_keel(repo, "--install", "--target", "codex", "--profile", "web")
1781
1790
  rejected_text = (rejected.stderr or "") + (rejected.stdout or "")
1782
- if rejected.returncode == 0 or "bundled" not in rejected_text:
1783
- report("domain-profiles scenario still accepts --profile.")
1791
+ if rejected.returncode == 0 or "keel/lenses" not in rejected_text:
1792
+ report("domain-lenses scenario still accepts --profile.")
1784
1793
  report(rejected_text.strip())
1785
1794
  return 1
1786
1795
 
@@ -1792,7 +1801,7 @@ def validate_domain_profiles_scenario() -> int:
1792
1801
  or "Keel profiles" in doctor_text
1793
1802
  ):
1794
1803
  report(
1795
- "domain-profiles scenario doctor still reports profile state or "
1804
+ "domain-lenses scenario doctor still reports profile state or "
1796
1805
  "misses the native plugin surface."
1797
1806
  )
1798
1807
  report(doctor_text.strip())
@@ -1805,11 +1814,11 @@ def validate_domain_profiles_scenario() -> int:
1805
1814
  else ""
1806
1815
  )
1807
1816
  if uninstall.returncode != 0 or "keel:start" in agents_text:
1808
- report("domain-profiles scenario uninstall left the managed bootstrap.")
1817
+ report("domain-lenses scenario uninstall left the managed bootstrap.")
1809
1818
  report((uninstall.stderr or uninstall.stdout).strip())
1810
1819
  return 1
1811
1820
 
1812
- report("domain-profiles scenario passed.")
1821
+ report("domain-lenses scenario passed.")
1813
1822
  return 0
1814
1823
 
1815
1824
 
@@ -2387,6 +2396,42 @@ def validate_update_pack_install_scenario() -> int:
2387
2396
  return 0
2388
2397
 
2389
2398
 
2399
+ def validate_update_default_registry_scenario() -> int:
2400
+ with tempfile.TemporaryDirectory(prefix="keel-update-default-") as raw_tmp:
2401
+ tmp = Path(raw_tmp)
2402
+ update = run_keel(tmp, "--update", "--dry-run")
2403
+ if update.returncode != 0:
2404
+ report("update-default-registry scenario keel --update --dry-run failed:")
2405
+ report((update.stderr or update.stdout).strip())
2406
+ return 1
2407
+
2408
+ pack_plan = (
2409
+ "would run npm pack" in update.stdout
2410
+ or "would run npm.cmd pack" in update.stdout
2411
+ )
2412
+ if not pack_plan:
2413
+ report("update-default-registry scenario did not report a pack plan.")
2414
+ report(update.stdout.strip())
2415
+ return 1
2416
+ if "@christang/keel" not in update.stdout:
2417
+ report(
2418
+ "update-default-registry scenario default source is not the "
2419
+ "published registry package @christang/keel."
2420
+ )
2421
+ report(update.stdout.strip())
2422
+ return 1
2423
+ if "github:" in update.stdout:
2424
+ report(
2425
+ "update-default-registry scenario default source is a git-type "
2426
+ "spec; self-update must default to the registry package."
2427
+ )
2428
+ report(update.stdout.strip())
2429
+ return 1
2430
+
2431
+ report("update-default-registry scenario passed.")
2432
+ return 0
2433
+
2434
+
2390
2435
  def run_keel_hook(repo: Path, event: dict) -> subprocess.CompletedProcess[str]:
2391
2436
  env = dict(os.environ)
2392
2437
  env["KEEL_CLI"] = str(ROOT / "bin/keel.js")
@@ -2633,7 +2678,7 @@ def validate_cli_scenario() -> int:
2633
2678
  if (
2634
2679
  update.returncode != 0
2635
2680
  or not pack_plan
2636
- or "github:TanglmChris/keel" not in update.stdout
2681
+ or "@christang/keel" not in update.stdout
2637
2682
  or not install_plan
2638
2683
  ):
2639
2684
  report("cli scenario keel --update did not report global CLI update plan.")
@@ -4508,11 +4553,23 @@ def validate_core_gates_scenario() -> int:
4508
4553
  "1.1",
4509
4554
  "--json",
4510
4555
  )
4556
+ handoff_payload = json.loads(handoff_owner.stdout)
4557
+ finding_owner_message = " ".join(
4558
+ problem.get("message", "")
4559
+ for problem in handoff_payload.get("problems", [])
4560
+ if problem.get("code") == "finding-owner"
4561
+ )
4511
4562
  if (
4512
4563
  handoff_owner.returncode != 3
4513
- or json.loads(handoff_owner.stdout).get("status") != "fail"
4564
+ or handoff_payload.get("status") != "fail"
4565
+ or "Discard reason" not in finding_owner_message
4566
+ or "keel/archive" not in finding_owner_message
4567
+ or "openspec/changes" not in finding_owner_message
4514
4568
  ):
4515
- report("core-gates scenario accepted HANDOFF as finding owner.")
4569
+ report(
4570
+ "core-gates scenario accepted HANDOFF as finding owner or the "
4571
+ "finding-owner error did not enumerate the accepted forms."
4572
+ )
4516
4573
  report((handoff_owner.stderr or handoff_owner.stdout).strip())
4517
4574
  return 1
4518
4575
 
@@ -4543,6 +4600,54 @@ def validate_core_gates_scenario() -> int:
4543
4600
  report((owned_finding.stderr or owned_finding.stdout).strip())
4544
4601
  return 1
4545
4602
 
4603
+ write_text(completion_tasks, completion_task("passed", "done"))
4604
+ done_status = run_keel(
4605
+ completion_repo,
4606
+ "gate",
4607
+ "task-complete",
4608
+ "--change",
4609
+ "demo",
4610
+ "--task",
4611
+ "1.1",
4612
+ "--json",
4613
+ )
4614
+ if (
4615
+ done_status.returncode != 0
4616
+ or json.loads(done_status.stdout).get("status") != "pass"
4617
+ ):
4618
+ report("core-gates scenario rejected an accepted `done` Review Status.")
4619
+ report((done_status.stderr or done_status.stdout).strip())
4620
+ return 1
4621
+
4622
+ write_text(completion_tasks, completion_task("passed", "reviewed"))
4623
+ bad_status = run_keel(
4624
+ completion_repo,
4625
+ "gate",
4626
+ "task-complete",
4627
+ "--change",
4628
+ "demo",
4629
+ "--task",
4630
+ "1.1",
4631
+ "--json",
4632
+ )
4633
+ bad_status_payload = json.loads(bad_status.stdout)
4634
+ bad_status_message = " ".join(
4635
+ problem.get("message", "")
4636
+ for problem in bad_status_payload.get("problems", [])
4637
+ )
4638
+ if (
4639
+ bad_status.returncode != 4
4640
+ or bad_status_payload.get("status") != "needs-review"
4641
+ or "Status" not in bad_status_message
4642
+ or "done" not in bad_status_message
4643
+ ):
4644
+ report(
4645
+ "core-gates scenario semantic-review did not name Status and list "
4646
+ "accepted tokens including done."
4647
+ )
4648
+ report((bad_status.stderr or bad_status.stdout).strip())
4649
+ return 1
4650
+
4546
4651
  write_text(completion_tasks, completion_task("passed", "pass"))
4547
4652
  write_text(completion_repo / "src/outside.js", "outside\n")
4548
4653
  subprocess.run(
@@ -5115,6 +5220,107 @@ def validate_core_gates_scenario() -> int:
5115
5220
  return 0
5116
5221
 
5117
5222
 
5223
+ def validate_scope_rename_attribution_scenario() -> int:
5224
+ rename_task = (
5225
+ "# Tasks\n\n"
5226
+ "- [ ] 1.1 Complete behavior\n"
5227
+ " - Owner: keel-agent\n"
5228
+ " - Mode: implementation\n"
5229
+ " - Covers:\n"
5230
+ " - E1: public behavior\n"
5231
+ " - Read:\n"
5232
+ " - README.md\n"
5233
+ " - Touch:\n"
5234
+ " - src/renamed-from.js\n"
5235
+ " - src/renamed-to.js\n"
5236
+ " - openspec/changes/demo/tasks.md\n"
5237
+ " - Commands:\n"
5238
+ " - M1: node test.js\n"
5239
+ " - Acceptance:\n"
5240
+ " - Public behavior passes.\n"
5241
+ " - Autonomy boundary:\n"
5242
+ " - Default: hard-stop\n"
5243
+ " - Pre-authorized fallback: none\n"
5244
+ " - Coupling: none\n"
5245
+ " - Candidate Boundary:\n"
5246
+ " - One complete candidate reaches M1.\n"
5247
+ " - Stop Rules:\n"
5248
+ " - Stop on final assertion failure.\n"
5249
+ " - Evidence:\n"
5250
+ " - M1: passed\n"
5251
+ " - Review:\n"
5252
+ " - Status: pass\n"
5253
+ " - Acceptance check: public behavior reviewed\n"
5254
+ " - Scope check: Touch reviewed semantically\n"
5255
+ " - Findings: none\n"
5256
+ " - Blocker: none\n"
5257
+ " - Stop if:\n"
5258
+ " - Requires files outside Touch.\n"
5259
+ " - Report:\n"
5260
+ " - Summary\n"
5261
+ )
5262
+ with tempfile.TemporaryDirectory(prefix="keel-scope-rename-") as raw_tmp:
5263
+ repo = Path(raw_tmp)
5264
+ write_text(repo / "openspec/changes/demo/tasks.md", rename_task)
5265
+ write_text(repo / "src/renamed-from.js", "module.exports = 1;\n")
5266
+ write_text(repo / "README.md", "readme\n")
5267
+ for args in (
5268
+ ["init", "--quiet"],
5269
+ ["config", "user.email", "keel@example.invalid"],
5270
+ ["config", "user.name", "Keel Fixture"],
5271
+ ["add", "."],
5272
+ ["commit", "--quiet", "-m", "baseline"],
5273
+ ):
5274
+ result = subprocess.run(
5275
+ ["git", *args], cwd=repo, capture_output=True, text=True
5276
+ )
5277
+ if result.returncode != 0:
5278
+ report("scope-rename scenario git setup failed:")
5279
+ report((result.stderr or result.stdout).strip())
5280
+ return 1
5281
+ # A staged rename whose old and new paths are both in Touch must not be a
5282
+ # false outside-Touch failure (git reports it as one `old -> new` entry).
5283
+ moved = subprocess.run(
5284
+ ["git", "mv", "src/renamed-from.js", "src/renamed-to.js"],
5285
+ cwd=repo,
5286
+ capture_output=True,
5287
+ text=True,
5288
+ )
5289
+ if moved.returncode != 0:
5290
+ report("scope-rename scenario git mv failed:")
5291
+ report((moved.stderr or moved.stdout).strip())
5292
+ return 1
5293
+ completed = run_keel(
5294
+ repo,
5295
+ "gate",
5296
+ "task-complete",
5297
+ "--change",
5298
+ "demo",
5299
+ "--task",
5300
+ "1.1",
5301
+ "--base",
5302
+ "HEAD",
5303
+ "--json",
5304
+ )
5305
+ payload = json.loads(completed.stdout or "{}")
5306
+ outside = " ".join(
5307
+ problem.get("message", "") for problem in payload.get("problems", [])
5308
+ )
5309
+ if (
5310
+ completed.returncode != 0
5311
+ or payload.get("status") != "pass"
5312
+ or "outside" in outside.lower()
5313
+ ):
5314
+ report(
5315
+ "scope-rename scenario reported a false outside-touch failure for a "
5316
+ "git mv rename whose old and new paths are both in Touch."
5317
+ )
5318
+ report((completed.stderr or completed.stdout).strip())
5319
+ return 1
5320
+ report("scope-rename scenario passed.")
5321
+ return 0
5322
+
5323
+
5118
5324
  def validate_target_capability_adapters_scenario() -> int:
5119
5325
  capability_keys = (
5120
5326
  "continuity.start",
@@ -5397,27 +5603,24 @@ def validate_expectation_alignment_skill_scenario() -> int:
5397
5603
  )
5398
5604
  return 1
5399
5605
 
5400
- # Structure and behavior: domain references exist, are routed from the
5401
- # skill, and stay scoped to their own domain (cross-file keyword pairs).
5606
+ # Structure and behavior: the skill routes to user-authored keel/lenses/,
5607
+ # the shipped lens templates exist, and each template stays scoped to its
5608
+ # own domain (cross-file keyword pairs).
5609
+ if "keel/lenses/" not in skill:
5610
+ report("expectation-alignment-skill does not route to keel/lenses/")
5611
+ return 1
5402
5612
  for domain in ("web", "hardware", "hardware-dsl"):
5403
- reference_path = (
5404
- ROOT / "src/skills/keel-align-expectations/references" / f"{domain}.md"
5405
- )
5406
- if not reference_path.is_file():
5407
- report(f"expectation-alignment-skill reference is missing: {domain}.md")
5408
- return 1
5409
- if f"references/{domain}.md" not in skill:
5410
- report(f"expectation-alignment-skill does not route to references/{domain}.md")
5411
- return 1
5412
- web_reference = (
5413
- ROOT / "src/skills/keel-align-expectations/references/web.md"
5414
- ).read_text(encoding="utf-8")
5415
- hardware_reference = (
5416
- ROOT / "src/skills/keel-align-expectations/references/hardware.md"
5417
- ).read_text(encoding="utf-8")
5418
- dsl_reference = (
5419
- ROOT / "src/skills/keel-align-expectations/references/hardware-dsl.md"
5420
- ).read_text(encoding="utf-8")
5613
+ template_path = ROOT / "assets/lenses" / f"{domain}.md"
5614
+ if not template_path.is_file():
5615
+ report(f"expectation-alignment-skill lens template is missing: {domain}.md")
5616
+ return 1
5617
+ web_reference = (ROOT / "assets/lenses/web.md").read_text(encoding="utf-8")
5618
+ hardware_reference = (ROOT / "assets/lenses/hardware.md").read_text(
5619
+ encoding="utf-8"
5620
+ )
5621
+ dsl_reference = (ROOT / "assets/lenses/hardware-dsl.md").read_text(
5622
+ encoding="utf-8"
5623
+ )
5421
5624
  for content, expected, unexpected in (
5422
5625
  (web_reference, "accessibility", "valid-ready"),
5423
5626
  (hardware_reference, "valid-ready", "browser"),
@@ -5425,7 +5628,7 @@ def validate_expectation_alignment_skill_scenario() -> int:
5425
5628
  ):
5426
5629
  if expected not in content or unexpected in content:
5427
5630
  report(
5428
- "expectation-alignment-skill domain references are not scoped to "
5631
+ "expectation-alignment-skill lens templates are not scoped to "
5429
5632
  "their own domain."
5430
5633
  )
5431
5634
  return 1
@@ -5563,11 +5766,10 @@ def validate_native_plugin_manifests_scenario() -> int:
5563
5766
  f"source: {skill_name}"
5564
5767
  )
5565
5768
  return 1
5566
- for reference in ("web.md", "hardware.md", "hardware-dsl.md"):
5567
- if not (
5568
- skills_root / "keel-align-expectations/references" / reference
5569
- ).is_file():
5570
- report(f"native-plugin-manifests plugin misses reference: {reference}")
5769
+ lenses_root = ROOT / "assets/lenses"
5770
+ for template in ("web.md", "hardware.md", "hardware-dsl.md"):
5771
+ if not (lenses_root / template).is_file():
5772
+ report(f"native-plugin-manifests misses lens template: {template}")
5571
5773
  return 1
5572
5774
 
5573
5775
  codex_market_path = ROOT / ".agents/plugins/marketplace.json"
@@ -6180,7 +6382,7 @@ def validate_compact_task_authoring_scenario() -> int:
6180
6382
  "- Strategy: <strategy>",
6181
6383
  "- M1:",
6182
6384
  "- Evidence:",
6183
- "- Contract: pending task-start capsule and fingerprint",
6385
+ "- Contract: pending",
6184
6386
  "- Review:",
6185
6387
  "- Blocker: none",
6186
6388
  "- Mode: diagnose-only",
@@ -9299,42 +9501,51 @@ def validate_domain_execution_references_scenario() -> int:
9299
9501
  "keel-debug-failure",
9300
9502
  "keel-review-checklist",
9301
9503
  )
9302
- references = ("web.md", "hardware.md", "hardware-dsl.md")
9504
+ templates = ("web.md", "hardware.md", "hardware-dsl.md")
9303
9505
  roots = (ROOT / "src/skills", ROOT / PLUGIN_ROOT / "skills")
9304
9506
 
9305
9507
  for root in roots:
9306
9508
  for skill in consuming_skills:
9307
9509
  text = (root / skill / "SKILL.md").read_text(encoding="utf-8")
9308
9510
  for needle in (
9309
- "keel-align-expectations",
9511
+ "keel/lenses/",
9310
9512
  "Execution and review checks",
9311
- "only the matching",
9312
- "no domain signal",
9513
+ "load only that one",
9514
+ "no lens matches",
9313
9515
  ):
9314
9516
  if needle not in text:
9315
9517
  report(
9316
9518
  f"domain-execution-references: {root.name}/{skill} lacks "
9317
- f"the on-demand consult step ({needle!r})."
9519
+ f"the on-demand lens consult step ({needle!r})."
9318
9520
  )
9319
9521
  return 1
9320
- for reference in references:
9321
- ref_path = (
9322
- root / "keel-align-expectations" / "references" / reference
9522
+
9523
+ lenses_root = ROOT / "assets/lenses"
9524
+ for template in templates:
9525
+ ref_path = lenses_root / template
9526
+ if not ref_path.is_file():
9527
+ report(f"domain-execution-references: lens template is missing: {template}")
9528
+ return 1
9529
+ text = ref_path.read_text(encoding="utf-8")
9530
+ if "## Execution and review checks" not in text:
9531
+ report(
9532
+ f"domain-execution-references: {template} lacks the "
9533
+ "execution and review section."
9323
9534
  )
9324
- text = ref_path.read_text(encoding="utf-8")
9325
- if "## Execution and review checks" not in text:
9326
- report(
9327
- f"domain-execution-references: {reference} lacks the "
9328
- "execution and review section."
9329
- )
9330
- return 1
9331
- size = ref_path.stat().st_size
9332
- if size > 3072:
9333
- report(
9334
- f"domain-execution-references: {reference} exceeds the 3072-"
9335
- f"byte budget ({size} bytes); move depth to dedicated skills."
9336
- )
9337
- return 1
9535
+ return 1
9536
+ if "Applies when" not in text:
9537
+ report(
9538
+ f"domain-execution-references: {template} lacks the "
9539
+ "self-describing 'Applies when' header."
9540
+ )
9541
+ return 1
9542
+ size = ref_path.stat().st_size
9543
+ if size > 3072:
9544
+ report(
9545
+ f"domain-execution-references: {template} exceeds the 3072-"
9546
+ f"byte budget ({size} bytes); move depth to dedicated skills."
9547
+ )
9548
+ return 1
9338
9549
 
9339
9550
  for skill in consuming_skills:
9340
9551
  src_text = (roots[0] / skill / "SKILL.md").read_bytes()
@@ -9345,19 +9556,6 @@ def validate_domain_execution_references_scenario() -> int:
9345
9556
  "from the canonical source."
9346
9557
  )
9347
9558
  return 1
9348
- for reference in references:
9349
- src_ref = (
9350
- roots[0] / "keel-align-expectations" / "references" / reference
9351
- ).read_bytes()
9352
- shipped_ref = (
9353
- roots[1] / "keel-align-expectations" / "references" / reference
9354
- ).read_bytes()
9355
- if src_ref != shipped_ref:
9356
- report(
9357
- f"domain-execution-references: shipped {reference} copy diverges "
9358
- "from the canonical source."
9359
- )
9360
- return 1
9361
9559
 
9362
9560
  readme = (ROOT / "README.zh-CN.md").read_text(encoding="utf-8")
9363
9561
  if "Execution and review checks" not in readme:
@@ -9377,6 +9575,129 @@ def validate_domain_execution_references_scenario() -> int:
9377
9575
  return 0
9378
9576
 
9379
9577
 
9578
+ def validate_domain_lens_scaffold_scenario() -> int:
9579
+ shipped = {"web", "hardware", "hardware-dsl"}
9580
+ lenses_root = ROOT / "assets/lenses"
9581
+ for name in shipped:
9582
+ if not (lenses_root / f"{name}.md").is_file():
9583
+ report(f"domain-lens-scaffold: shipped template is missing: {name}")
9584
+ return 1
9585
+
9586
+ with tempfile.TemporaryDirectory(prefix="keel-lenses-") as raw_tmp:
9587
+ repo = Path(raw_tmp) / "repo"
9588
+ repo.mkdir()
9589
+
9590
+ listing = run_keel(repo, "lenses", "list")
9591
+ if listing.returncode != 0:
9592
+ report("domain-lens-scaffold: keel lenses list failed.")
9593
+ report((listing.stderr or listing.stdout).strip())
9594
+ return 1
9595
+ for name in shipped:
9596
+ if name not in listing.stdout:
9597
+ report(
9598
+ f"domain-lens-scaffold: keel lenses list omitted the "
9599
+ f"{name} template."
9600
+ )
9601
+ report(listing.stdout.strip())
9602
+ return 1
9603
+ if "keel/lenses/" not in listing.stdout:
9604
+ report(
9605
+ "domain-lens-scaffold: keel lenses list did not name the "
9606
+ "keel/lenses/ install location."
9607
+ )
9608
+ return 1
9609
+
9610
+ add = run_keel(repo, "lenses", "add", "web")
9611
+ installed = repo / "keel/lenses/web.md"
9612
+ if add.returncode != 0 or not installed.is_file():
9613
+ report("domain-lens-scaffold: keel lenses add web did not scaffold.")
9614
+ report((add.stderr or add.stdout).strip())
9615
+ return 1
9616
+ if "Applies when:" not in installed.read_text(encoding="utf-8"):
9617
+ report(
9618
+ "domain-lens-scaffold: scaffolded lens lost its 'Applies when:' "
9619
+ "header."
9620
+ )
9621
+ return 1
9622
+
9623
+ clobber = run_keel(repo, "lenses", "add", "web")
9624
+ if clobber.returncode == 0:
9625
+ report(
9626
+ "domain-lens-scaffold: a second keel lenses add web must refuse "
9627
+ "without --force."
9628
+ )
9629
+ return 1
9630
+
9631
+ forced = run_keel(repo, "lenses", "add", "web", "--force")
9632
+ if forced.returncode != 0:
9633
+ report("domain-lens-scaffold: keel lenses add web --force failed.")
9634
+ report((forced.stderr or forced.stdout).strip())
9635
+ return 1
9636
+
9637
+ after_add = run_keel(repo, "lenses", "list")
9638
+ if "web (installed)" not in after_add.stdout:
9639
+ report(
9640
+ "domain-lens-scaffold: keel lenses list did not mark web as "
9641
+ "installed after add."
9642
+ )
9643
+ report(after_add.stdout.strip())
9644
+ return 1
9645
+
9646
+ report("domain-lens-scaffold scenario passed.")
9647
+ return 0
9648
+
9649
+
9650
+ def validate_domain_lens_doctor_scenario() -> int:
9651
+ with tempfile.TemporaryDirectory(
9652
+ prefix="keel-lens-doctor-", ignore_cleanup_errors=True
9653
+ ) as raw_tmp:
9654
+ repo = Path(raw_tmp) / "repo"
9655
+ repo.mkdir()
9656
+
9657
+ doctor = run_keel(repo, "--doctor")
9658
+ out = doctor.stdout
9659
+ if "Domain lens surface:" not in out:
9660
+ report("domain-lens-doctor: --doctor lacks the Domain lens surface section.")
9661
+ report((doctor.stderr or out).strip())
9662
+ return 1
9663
+ for name in ("web", "hardware", "hardware-dsl"):
9664
+ if name not in out:
9665
+ report(f"domain-lens-doctor: --doctor did not report the {name} template.")
9666
+ report(out.strip())
9667
+ return 1
9668
+ if "lens templates: ok" not in out:
9669
+ report("domain-lens-doctor: --doctor did not report the shipped templates as ok.")
9670
+ report(out.strip())
9671
+ return 1
9672
+
9673
+ legacy = repo / ".claude/skills/keel-profile-web/SKILL.md"
9674
+ legacy.parent.mkdir(parents=True)
9675
+ legacy.write_text("legacy profile bytes\n", encoding="utf-8")
9676
+ before = legacy.read_bytes()
9677
+
9678
+ doctor2 = run_keel(repo, "--doctor")
9679
+ out2 = doctor2.stdout
9680
+ if "legacy profiles: migrate" not in out2 or "keel-profile-web" not in out2:
9681
+ report(
9682
+ "domain-lens-doctor: --doctor did not warn about the legacy "
9683
+ "keel-profile-web skill."
9684
+ )
9685
+ report(out2.strip())
9686
+ return 1
9687
+ if "not active state" not in out2:
9688
+ report(
9689
+ "domain-lens-doctor: the legacy warning must state it is not "
9690
+ "active state."
9691
+ )
9692
+ return 1
9693
+ if legacy.read_bytes() != before:
9694
+ report("domain-lens-doctor: --doctor modified the legacy skill bytes.")
9695
+ return 1
9696
+
9697
+ report("domain-lens-doctor scenario passed.")
9698
+ return 0
9699
+
9700
+
9380
9701
  def validate_precompact_probe_scenario() -> int:
9381
9702
  hooks_config = json.loads(
9382
9703
  (ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
@@ -9719,9 +10040,54 @@ def validate_validation_runner_scenario() -> int:
9719
10040
 
9720
10041
  # The one ordered scenario registry: --scenario dispatch, the --all runner,
9721
10042
  # and registration assertions all read this list and nothing else.
10043
+ def validate_doctor_openspec_honesty_scenario() -> int:
10044
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10045
+ if "is not on PATH" not in cli:
10046
+ report(
10047
+ "doctor-openspec-honesty scenario: bin/keel.js is missing the "
10048
+ "openspec PATH-reachability warning branch."
10049
+ )
10050
+ return 1
10051
+ with tempfile.TemporaryDirectory(prefix="keel-doctor-openspec-") as raw:
10052
+ root = Path(raw)
10053
+ repo = root / "repo"
10054
+ repo.mkdir()
10055
+ stub_dir = root / "stub"
10056
+ stub_dir.mkdir()
10057
+ if os.name == "nt":
10058
+ (stub_dir / "openspec.cmd").write_text(
10059
+ "@echo off\necho 1.6.0\n", encoding="utf-8"
10060
+ )
10061
+ else:
10062
+ stub = stub_dir / "openspec"
10063
+ stub.write_text("#!/bin/sh\necho 1.6.0\n", encoding="utf-8")
10064
+ stub.chmod(0o755)
10065
+ env = dict(os.environ)
10066
+ env["PATH"] = str(stub_dir) + os.pathsep + env.get("PATH", "")
10067
+ on_path = run_keel(repo, "--doctor", env=env)
10068
+ openspec_line = next(
10069
+ (
10070
+ line
10071
+ for line in on_path.stdout.splitlines()
10072
+ if line.startswith("openspec:")
10073
+ ),
10074
+ "",
10075
+ )
10076
+ if not openspec_line.startswith("openspec: ok"):
10077
+ report(
10078
+ "doctor-openspec-honesty scenario: doctor did not report `ok` "
10079
+ "when a bare openspec is on PATH."
10080
+ )
10081
+ report(on_path.stdout.strip())
10082
+ return 1
10083
+ report("doctor-openspec-honesty scenario passed.")
10084
+ return 0
10085
+
10086
+
9722
10087
  SCENARIOS: tuple = (
9723
10088
  ("stateless-continuity", validate_stateless_continuity_scenario),
9724
10089
  ("core-gates", validate_core_gates_scenario),
10090
+ ("scope-rename-attribution", validate_scope_rename_attribution_scenario),
9725
10091
  ("target-capability-adapters", validate_target_capability_adapters_scenario),
9726
10092
  ("native-runtime-projection", validate_native_runtime_projection_scenario),
9727
10093
  ("target-surface", validate_target_surface_scenario),
@@ -9729,13 +10095,15 @@ SCENARIOS: tuple = (
9729
10095
  ("expectation-slice-gates", validate_expectation_slice_gates_scenario),
9730
10096
  ("expectation-completion-gates", validate_expectation_completion_gates_scenario),
9731
10097
  ("authoring-continuity", validate_authoring_continuity_scenario),
9732
- ("domain-profiles", validate_domain_profiles_scenario),
10098
+ ("domain-lenses", validate_domain_lenses_scenario),
9733
10099
  ("skill-portability-policy", validate_skill_portability_policy_scenario),
9734
10100
  ("version-alignment", validate_version_alignment_scenario),
9735
10101
  ("openspec-surface-overlay", validate_openspec_surface_overlay_scenario),
9736
10102
  ("uninstall", validate_uninstall_scenario),
9737
10103
  ("cli", validate_cli_scenario),
10104
+ ("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
9738
10105
  ("update-pack-install", validate_update_pack_install_scenario),
10106
+ ("update-default-registry", validate_update_default_registry_scenario),
9739
10107
  ("task-contract-core", validate_task_contract_core_scenario),
9740
10108
  ("task-capsule", validate_task_capsule_scenario),
9741
10109
  ("task-verification-strategies", validate_task_verification_strategies_scenario),
@@ -9768,6 +10136,8 @@ SCENARIOS: tuple = (
9768
10136
  ("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
9769
10137
  ("precompact-probe", validate_precompact_probe_scenario),
9770
10138
  ("domain-execution-references", validate_domain_execution_references_scenario),
10139
+ ("domain-lens-scaffold", validate_domain_lens_scaffold_scenario),
10140
+ ("domain-lens-doctor", validate_domain_lens_doctor_scenario),
9771
10141
  ("plan-funnel-guidance", validate_plan_funnel_guidance_scenario),
9772
10142
  ("native-tasks-view", validate_native_tasks_view_scenario),
9773
10143
  ("validation-runner", validate_validation_runner_scenario),
@@ -9800,6 +10170,83 @@ def run_scenario_processes(names: list, jobs: int) -> list:
9800
10170
  return [(name, *results[name]) for name in names]
9801
10171
 
9802
10172
 
10173
+ def validate_review_status_single_source(errors: list[str]) -> None:
10174
+ contract = (ROOT / "src/core/task-contract.js").read_text(encoding="utf-8")
10175
+ if "ACCEPTED_REVIEW_STATUSES" not in contract:
10176
+ errors.append(
10177
+ "src/core/task-contract.js must define the shared "
10178
+ "ACCEPTED_REVIEW_STATUSES constant."
10179
+ )
10180
+ if '"done"' not in contract:
10181
+ errors.append(
10182
+ "ACCEPTED_REVIEW_STATUSES must include the `done` Review Status."
10183
+ )
10184
+ literal = "pass|passed|complete|completed|ok"
10185
+ for relative in ("src/core/gates.js", "src/core/context.js"):
10186
+ content = (ROOT / relative).read_text(encoding="utf-8")
10187
+ if "ACCEPTED_REVIEW_STATUSES" not in content:
10188
+ errors.append(
10189
+ f"{relative} must consume the shared ACCEPTED_REVIEW_STATUSES "
10190
+ "constant rather than an inline Review Status vocabulary."
10191
+ )
10192
+ if literal in content:
10193
+ errors.append(
10194
+ f"{relative} still hard-codes the Review Status vocabulary "
10195
+ f"`{literal}`; consume the shared constant instead."
10196
+ )
10197
+
10198
+
10199
+ def validate_install_honesty(errors: list[str]) -> None:
10200
+ installer = (ROOT / "scripts/install_to_repo.py").read_text(encoding="utf-8")
10201
+ if "def skill_actions" in installer or "dist_asset(target_name, \"skills\")" in installer:
10202
+ errors.append(
10203
+ "install_to_repo.py must not carry the dead keel-* skill_actions "
10204
+ "path; keel-* skills are plugin-delivered."
10205
+ )
10206
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10207
+ if "delivered by the installed Keel plugin" not in cli:
10208
+ errors.append(
10209
+ "keel --help/--doctor must state that keel-* skills are delivered "
10210
+ "by the installed Keel plugin, not installed by the CLI."
10211
+ )
10212
+ if ".claude/skills/keel-*." in cli:
10213
+ errors.append(
10214
+ "keel --help must not claim the CLI installs skills under "
10215
+ ".claude/skills/keel-*."
10216
+ )
10217
+
10218
+
10219
+ def validate_openspec_invocable(errors: list[str]) -> None:
10220
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10221
+ if 'options.action === "openspec"' not in cli:
10222
+ errors.append("bin/keel.js must implement the `keel openspec` passthrough.")
10223
+ if cli.count("Invoke OpenSpec through") < 2:
10224
+ errors.append(
10225
+ "the apply and archive overlays must direct agents to `keel openspec` "
10226
+ "in place of a bare openspec command."
10227
+ )
10228
+
10229
+
10230
+ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
10231
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10232
+ if "--skip-specs" not in cli:
10233
+ errors.append(
10234
+ "the archive overlay must direct archive to pass --skip-specs "
10235
+ "after /opsx:sync has promoted the delta."
10236
+ )
10237
+ if "drop the change's guard manifest" not in cli:
10238
+ errors.append(
10239
+ "the archive overlay must remind the agent to run keel guard clear "
10240
+ "after archiving."
10241
+ )
10242
+ gates = (ROOT / "src/core/gates.js").read_text(encoding="utf-8")
10243
+ if "clearGuard" in gates:
10244
+ errors.append(
10245
+ "gates must stay read-only: gate code must not clear the guard "
10246
+ "manifest (guard clear is explicit via keel guard clear)."
10247
+ )
10248
+
10249
+
9803
10250
  def run_baseline() -> int:
9804
10251
  errors: list[str] = []
9805
10252
  validate_manifest(errors)
@@ -9808,6 +10255,10 @@ def run_baseline() -> int:
9808
10255
  validate_resident_blocks(errors)
9809
10256
  validate_templates(errors)
9810
10257
  validate_openspec_schema(errors)
10258
+ validate_review_status_single_source(errors)
10259
+ validate_install_honesty(errors)
10260
+ validate_openspec_invocable(errors)
10261
+ validate_archive_overlay_hygiene(errors)
9811
10262
  validate_skill_docs(errors)
9812
10263
  validate_skill_portability(ROOT, errors)
9813
10264
  validate_scripts_use_stdlib(errors)