@christang/keel 5.1.2 → 5.2.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.
@@ -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.0"
41
+ PROTOCOL_VERSION = "5.2.0"
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]]] = [
@@ -1742,16 +1752,15 @@ def validate_authoring_continuity_scenario() -> int:
1742
1752
 
1743
1753
  def validate_domain_profiles_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-profiles 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-profiles scenario misses the shipped lens template: "
1763
+ f"{template}"
1755
1764
  )
1756
1765
  return 1
1757
1766
  for legacy_skill in LEGACY_PROFILE_SKILLS:
@@ -4508,11 +4517,23 @@ def validate_core_gates_scenario() -> int:
4508
4517
  "1.1",
4509
4518
  "--json",
4510
4519
  )
4520
+ handoff_payload = json.loads(handoff_owner.stdout)
4521
+ finding_owner_message = " ".join(
4522
+ problem.get("message", "")
4523
+ for problem in handoff_payload.get("problems", [])
4524
+ if problem.get("code") == "finding-owner"
4525
+ )
4511
4526
  if (
4512
4527
  handoff_owner.returncode != 3
4513
- or json.loads(handoff_owner.stdout).get("status") != "fail"
4528
+ or handoff_payload.get("status") != "fail"
4529
+ or "Discard reason" not in finding_owner_message
4530
+ or "keel/archive" not in finding_owner_message
4531
+ or "openspec/changes" not in finding_owner_message
4514
4532
  ):
4515
- report("core-gates scenario accepted HANDOFF as finding owner.")
4533
+ report(
4534
+ "core-gates scenario accepted HANDOFF as finding owner or the "
4535
+ "finding-owner error did not enumerate the accepted forms."
4536
+ )
4516
4537
  report((handoff_owner.stderr or handoff_owner.stdout).strip())
4517
4538
  return 1
4518
4539
 
@@ -4543,6 +4564,54 @@ def validate_core_gates_scenario() -> int:
4543
4564
  report((owned_finding.stderr or owned_finding.stdout).strip())
4544
4565
  return 1
4545
4566
 
4567
+ write_text(completion_tasks, completion_task("passed", "done"))
4568
+ done_status = run_keel(
4569
+ completion_repo,
4570
+ "gate",
4571
+ "task-complete",
4572
+ "--change",
4573
+ "demo",
4574
+ "--task",
4575
+ "1.1",
4576
+ "--json",
4577
+ )
4578
+ if (
4579
+ done_status.returncode != 0
4580
+ or json.loads(done_status.stdout).get("status") != "pass"
4581
+ ):
4582
+ report("core-gates scenario rejected an accepted `done` Review Status.")
4583
+ report((done_status.stderr or done_status.stdout).strip())
4584
+ return 1
4585
+
4586
+ write_text(completion_tasks, completion_task("passed", "reviewed"))
4587
+ bad_status = run_keel(
4588
+ completion_repo,
4589
+ "gate",
4590
+ "task-complete",
4591
+ "--change",
4592
+ "demo",
4593
+ "--task",
4594
+ "1.1",
4595
+ "--json",
4596
+ )
4597
+ bad_status_payload = json.loads(bad_status.stdout)
4598
+ bad_status_message = " ".join(
4599
+ problem.get("message", "")
4600
+ for problem in bad_status_payload.get("problems", [])
4601
+ )
4602
+ if (
4603
+ bad_status.returncode != 4
4604
+ or bad_status_payload.get("status") != "needs-review"
4605
+ or "Status" not in bad_status_message
4606
+ or "done" not in bad_status_message
4607
+ ):
4608
+ report(
4609
+ "core-gates scenario semantic-review did not name Status and list "
4610
+ "accepted tokens including done."
4611
+ )
4612
+ report((bad_status.stderr or bad_status.stdout).strip())
4613
+ return 1
4614
+
4546
4615
  write_text(completion_tasks, completion_task("passed", "pass"))
4547
4616
  write_text(completion_repo / "src/outside.js", "outside\n")
4548
4617
  subprocess.run(
@@ -5397,27 +5466,24 @@ def validate_expectation_alignment_skill_scenario() -> int:
5397
5466
  )
5398
5467
  return 1
5399
5468
 
5400
- # Structure and behavior: domain references exist, are routed from the
5401
- # skill, and stay scoped to their own domain (cross-file keyword pairs).
5469
+ # Structure and behavior: the skill routes to user-authored keel/lenses/,
5470
+ # the shipped lens templates exist, and each template stays scoped to its
5471
+ # own domain (cross-file keyword pairs).
5472
+ if "keel/lenses/" not in skill:
5473
+ report("expectation-alignment-skill does not route to keel/lenses/")
5474
+ return 1
5402
5475
  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")
5476
+ template_path = ROOT / "assets/lenses" / f"{domain}.md"
5477
+ if not template_path.is_file():
5478
+ report(f"expectation-alignment-skill lens template is missing: {domain}.md")
5479
+ return 1
5480
+ web_reference = (ROOT / "assets/lenses/web.md").read_text(encoding="utf-8")
5481
+ hardware_reference = (ROOT / "assets/lenses/hardware.md").read_text(
5482
+ encoding="utf-8"
5483
+ )
5484
+ dsl_reference = (ROOT / "assets/lenses/hardware-dsl.md").read_text(
5485
+ encoding="utf-8"
5486
+ )
5421
5487
  for content, expected, unexpected in (
5422
5488
  (web_reference, "accessibility", "valid-ready"),
5423
5489
  (hardware_reference, "valid-ready", "browser"),
@@ -5425,7 +5491,7 @@ def validate_expectation_alignment_skill_scenario() -> int:
5425
5491
  ):
5426
5492
  if expected not in content or unexpected in content:
5427
5493
  report(
5428
- "expectation-alignment-skill domain references are not scoped to "
5494
+ "expectation-alignment-skill lens templates are not scoped to "
5429
5495
  "their own domain."
5430
5496
  )
5431
5497
  return 1
@@ -5563,11 +5629,10 @@ def validate_native_plugin_manifests_scenario() -> int:
5563
5629
  f"source: {skill_name}"
5564
5630
  )
5565
5631
  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}")
5632
+ lenses_root = ROOT / "assets/lenses"
5633
+ for template in ("web.md", "hardware.md", "hardware-dsl.md"):
5634
+ if not (lenses_root / template).is_file():
5635
+ report(f"native-plugin-manifests misses lens template: {template}")
5571
5636
  return 1
5572
5637
 
5573
5638
  codex_market_path = ROOT / ".agents/plugins/marketplace.json"
@@ -6180,7 +6245,7 @@ def validate_compact_task_authoring_scenario() -> int:
6180
6245
  "- Strategy: <strategy>",
6181
6246
  "- M1:",
6182
6247
  "- Evidence:",
6183
- "- Contract: pending task-start capsule and fingerprint",
6248
+ "- Contract: pending",
6184
6249
  "- Review:",
6185
6250
  "- Blocker: none",
6186
6251
  "- Mode: diagnose-only",
@@ -9299,42 +9364,51 @@ def validate_domain_execution_references_scenario() -> int:
9299
9364
  "keel-debug-failure",
9300
9365
  "keel-review-checklist",
9301
9366
  )
9302
- references = ("web.md", "hardware.md", "hardware-dsl.md")
9367
+ templates = ("web.md", "hardware.md", "hardware-dsl.md")
9303
9368
  roots = (ROOT / "src/skills", ROOT / PLUGIN_ROOT / "skills")
9304
9369
 
9305
9370
  for root in roots:
9306
9371
  for skill in consuming_skills:
9307
9372
  text = (root / skill / "SKILL.md").read_text(encoding="utf-8")
9308
9373
  for needle in (
9309
- "keel-align-expectations",
9374
+ "keel/lenses/",
9310
9375
  "Execution and review checks",
9311
- "only the matching",
9312
- "no domain signal",
9376
+ "load only that one",
9377
+ "no lens matches",
9313
9378
  ):
9314
9379
  if needle not in text:
9315
9380
  report(
9316
9381
  f"domain-execution-references: {root.name}/{skill} lacks "
9317
- f"the on-demand consult step ({needle!r})."
9382
+ f"the on-demand lens consult step ({needle!r})."
9318
9383
  )
9319
9384
  return 1
9320
- for reference in references:
9321
- ref_path = (
9322
- root / "keel-align-expectations" / "references" / reference
9385
+
9386
+ lenses_root = ROOT / "assets/lenses"
9387
+ for template in templates:
9388
+ ref_path = lenses_root / template
9389
+ if not ref_path.is_file():
9390
+ report(f"domain-execution-references: lens template is missing: {template}")
9391
+ return 1
9392
+ text = ref_path.read_text(encoding="utf-8")
9393
+ if "## Execution and review checks" not in text:
9394
+ report(
9395
+ f"domain-execution-references: {template} lacks the "
9396
+ "execution and review section."
9323
9397
  )
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
9398
+ return 1
9399
+ if "Applies when" not in text:
9400
+ report(
9401
+ f"domain-execution-references: {template} lacks the "
9402
+ "self-describing 'Applies when' header."
9403
+ )
9404
+ return 1
9405
+ size = ref_path.stat().st_size
9406
+ if size > 3072:
9407
+ report(
9408
+ f"domain-execution-references: {template} exceeds the 3072-"
9409
+ f"byte budget ({size} bytes); move depth to dedicated skills."
9410
+ )
9411
+ return 1
9338
9412
 
9339
9413
  for skill in consuming_skills:
9340
9414
  src_text = (roots[0] / skill / "SKILL.md").read_bytes()
@@ -9345,19 +9419,6 @@ def validate_domain_execution_references_scenario() -> int:
9345
9419
  "from the canonical source."
9346
9420
  )
9347
9421
  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
9422
 
9362
9423
  readme = (ROOT / "README.zh-CN.md").read_text(encoding="utf-8")
9363
9424
  if "Execution and review checks" not in readme:
@@ -9377,6 +9438,129 @@ def validate_domain_execution_references_scenario() -> int:
9377
9438
  return 0
9378
9439
 
9379
9440
 
9441
+ def validate_domain_lens_scaffold_scenario() -> int:
9442
+ shipped = {"web", "hardware", "hardware-dsl"}
9443
+ lenses_root = ROOT / "assets/lenses"
9444
+ for name in shipped:
9445
+ if not (lenses_root / f"{name}.md").is_file():
9446
+ report(f"domain-lens-scaffold: shipped template is missing: {name}")
9447
+ return 1
9448
+
9449
+ with tempfile.TemporaryDirectory(prefix="keel-lenses-") as raw_tmp:
9450
+ repo = Path(raw_tmp) / "repo"
9451
+ repo.mkdir()
9452
+
9453
+ listing = run_keel(repo, "lenses", "list")
9454
+ if listing.returncode != 0:
9455
+ report("domain-lens-scaffold: keel lenses list failed.")
9456
+ report((listing.stderr or listing.stdout).strip())
9457
+ return 1
9458
+ for name in shipped:
9459
+ if name not in listing.stdout:
9460
+ report(
9461
+ f"domain-lens-scaffold: keel lenses list omitted the "
9462
+ f"{name} template."
9463
+ )
9464
+ report(listing.stdout.strip())
9465
+ return 1
9466
+ if "keel/lenses/" not in listing.stdout:
9467
+ report(
9468
+ "domain-lens-scaffold: keel lenses list did not name the "
9469
+ "keel/lenses/ install location."
9470
+ )
9471
+ return 1
9472
+
9473
+ add = run_keel(repo, "lenses", "add", "web")
9474
+ installed = repo / "keel/lenses/web.md"
9475
+ if add.returncode != 0 or not installed.is_file():
9476
+ report("domain-lens-scaffold: keel lenses add web did not scaffold.")
9477
+ report((add.stderr or add.stdout).strip())
9478
+ return 1
9479
+ if "Applies when:" not in installed.read_text(encoding="utf-8"):
9480
+ report(
9481
+ "domain-lens-scaffold: scaffolded lens lost its 'Applies when:' "
9482
+ "header."
9483
+ )
9484
+ return 1
9485
+
9486
+ clobber = run_keel(repo, "lenses", "add", "web")
9487
+ if clobber.returncode == 0:
9488
+ report(
9489
+ "domain-lens-scaffold: a second keel lenses add web must refuse "
9490
+ "without --force."
9491
+ )
9492
+ return 1
9493
+
9494
+ forced = run_keel(repo, "lenses", "add", "web", "--force")
9495
+ if forced.returncode != 0:
9496
+ report("domain-lens-scaffold: keel lenses add web --force failed.")
9497
+ report((forced.stderr or forced.stdout).strip())
9498
+ return 1
9499
+
9500
+ after_add = run_keel(repo, "lenses", "list")
9501
+ if "web (installed)" not in after_add.stdout:
9502
+ report(
9503
+ "domain-lens-scaffold: keel lenses list did not mark web as "
9504
+ "installed after add."
9505
+ )
9506
+ report(after_add.stdout.strip())
9507
+ return 1
9508
+
9509
+ report("domain-lens-scaffold scenario passed.")
9510
+ return 0
9511
+
9512
+
9513
+ def validate_domain_lens_doctor_scenario() -> int:
9514
+ with tempfile.TemporaryDirectory(
9515
+ prefix="keel-lens-doctor-", ignore_cleanup_errors=True
9516
+ ) as raw_tmp:
9517
+ repo = Path(raw_tmp) / "repo"
9518
+ repo.mkdir()
9519
+
9520
+ doctor = run_keel(repo, "--doctor")
9521
+ out = doctor.stdout
9522
+ if "Domain lens surface:" not in out:
9523
+ report("domain-lens-doctor: --doctor lacks the Domain lens surface section.")
9524
+ report((doctor.stderr or out).strip())
9525
+ return 1
9526
+ for name in ("web", "hardware", "hardware-dsl"):
9527
+ if name not in out:
9528
+ report(f"domain-lens-doctor: --doctor did not report the {name} template.")
9529
+ report(out.strip())
9530
+ return 1
9531
+ if "lens templates: ok" not in out:
9532
+ report("domain-lens-doctor: --doctor did not report the shipped templates as ok.")
9533
+ report(out.strip())
9534
+ return 1
9535
+
9536
+ legacy = repo / ".claude/skills/keel-profile-web/SKILL.md"
9537
+ legacy.parent.mkdir(parents=True)
9538
+ legacy.write_text("legacy profile bytes\n", encoding="utf-8")
9539
+ before = legacy.read_bytes()
9540
+
9541
+ doctor2 = run_keel(repo, "--doctor")
9542
+ out2 = doctor2.stdout
9543
+ if "legacy profiles: migrate" not in out2 or "keel-profile-web" not in out2:
9544
+ report(
9545
+ "domain-lens-doctor: --doctor did not warn about the legacy "
9546
+ "keel-profile-web skill."
9547
+ )
9548
+ report(out2.strip())
9549
+ return 1
9550
+ if "not active state" not in out2:
9551
+ report(
9552
+ "domain-lens-doctor: the legacy warning must state it is not "
9553
+ "active state."
9554
+ )
9555
+ return 1
9556
+ if legacy.read_bytes() != before:
9557
+ report("domain-lens-doctor: --doctor modified the legacy skill bytes.")
9558
+ return 1
9559
+
9560
+ report("domain-lens-doctor scenario passed.")
9561
+ return 0
9562
+
9563
+
9380
9564
  def validate_precompact_probe_scenario() -> int:
9381
9565
  hooks_config = json.loads(
9382
9566
  (ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
@@ -9719,6 +9903,50 @@ def validate_validation_runner_scenario() -> int:
9719
9903
 
9720
9904
  # The one ordered scenario registry: --scenario dispatch, the --all runner,
9721
9905
  # and registration assertions all read this list and nothing else.
9906
+ def validate_doctor_openspec_honesty_scenario() -> int:
9907
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
9908
+ if "is not on PATH" not in cli:
9909
+ report(
9910
+ "doctor-openspec-honesty scenario: bin/keel.js is missing the "
9911
+ "openspec PATH-reachability warning branch."
9912
+ )
9913
+ return 1
9914
+ with tempfile.TemporaryDirectory(prefix="keel-doctor-openspec-") as raw:
9915
+ root = Path(raw)
9916
+ repo = root / "repo"
9917
+ repo.mkdir()
9918
+ stub_dir = root / "stub"
9919
+ stub_dir.mkdir()
9920
+ if os.name == "nt":
9921
+ (stub_dir / "openspec.cmd").write_text(
9922
+ "@echo off\necho 1.6.0\n", encoding="utf-8"
9923
+ )
9924
+ else:
9925
+ stub = stub_dir / "openspec"
9926
+ stub.write_text("#!/bin/sh\necho 1.6.0\n", encoding="utf-8")
9927
+ stub.chmod(0o755)
9928
+ env = dict(os.environ)
9929
+ env["PATH"] = str(stub_dir) + os.pathsep + env.get("PATH", "")
9930
+ on_path = run_keel(repo, "--doctor", env=env)
9931
+ openspec_line = next(
9932
+ (
9933
+ line
9934
+ for line in on_path.stdout.splitlines()
9935
+ if line.startswith("openspec:")
9936
+ ),
9937
+ "",
9938
+ )
9939
+ if not openspec_line.startswith("openspec: ok"):
9940
+ report(
9941
+ "doctor-openspec-honesty scenario: doctor did not report `ok` "
9942
+ "when a bare openspec is on PATH."
9943
+ )
9944
+ report(on_path.stdout.strip())
9945
+ return 1
9946
+ report("doctor-openspec-honesty scenario passed.")
9947
+ return 0
9948
+
9949
+
9722
9950
  SCENARIOS: tuple = (
9723
9951
  ("stateless-continuity", validate_stateless_continuity_scenario),
9724
9952
  ("core-gates", validate_core_gates_scenario),
@@ -9735,6 +9963,7 @@ SCENARIOS: tuple = (
9735
9963
  ("openspec-surface-overlay", validate_openspec_surface_overlay_scenario),
9736
9964
  ("uninstall", validate_uninstall_scenario),
9737
9965
  ("cli", validate_cli_scenario),
9966
+ ("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
9738
9967
  ("update-pack-install", validate_update_pack_install_scenario),
9739
9968
  ("task-contract-core", validate_task_contract_core_scenario),
9740
9969
  ("task-capsule", validate_task_capsule_scenario),
@@ -9768,6 +9997,8 @@ SCENARIOS: tuple = (
9768
9997
  ("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
9769
9998
  ("precompact-probe", validate_precompact_probe_scenario),
9770
9999
  ("domain-execution-references", validate_domain_execution_references_scenario),
10000
+ ("domain-lens-scaffold", validate_domain_lens_scaffold_scenario),
10001
+ ("domain-lens-doctor", validate_domain_lens_doctor_scenario),
9771
10002
  ("plan-funnel-guidance", validate_plan_funnel_guidance_scenario),
9772
10003
  ("native-tasks-view", validate_native_tasks_view_scenario),
9773
10004
  ("validation-runner", validate_validation_runner_scenario),
@@ -9800,6 +10031,83 @@ def run_scenario_processes(names: list, jobs: int) -> list:
9800
10031
  return [(name, *results[name]) for name in names]
9801
10032
 
9802
10033
 
10034
+ def validate_review_status_single_source(errors: list[str]) -> None:
10035
+ contract = (ROOT / "src/core/task-contract.js").read_text(encoding="utf-8")
10036
+ if "ACCEPTED_REVIEW_STATUSES" not in contract:
10037
+ errors.append(
10038
+ "src/core/task-contract.js must define the shared "
10039
+ "ACCEPTED_REVIEW_STATUSES constant."
10040
+ )
10041
+ if '"done"' not in contract:
10042
+ errors.append(
10043
+ "ACCEPTED_REVIEW_STATUSES must include the `done` Review Status."
10044
+ )
10045
+ literal = "pass|passed|complete|completed|ok"
10046
+ for relative in ("src/core/gates.js", "src/core/context.js"):
10047
+ content = (ROOT / relative).read_text(encoding="utf-8")
10048
+ if "ACCEPTED_REVIEW_STATUSES" not in content:
10049
+ errors.append(
10050
+ f"{relative} must consume the shared ACCEPTED_REVIEW_STATUSES "
10051
+ "constant rather than an inline Review Status vocabulary."
10052
+ )
10053
+ if literal in content:
10054
+ errors.append(
10055
+ f"{relative} still hard-codes the Review Status vocabulary "
10056
+ f"`{literal}`; consume the shared constant instead."
10057
+ )
10058
+
10059
+
10060
+ def validate_install_honesty(errors: list[str]) -> None:
10061
+ installer = (ROOT / "scripts/install_to_repo.py").read_text(encoding="utf-8")
10062
+ if "def skill_actions" in installer or "dist_asset(target_name, \"skills\")" in installer:
10063
+ errors.append(
10064
+ "install_to_repo.py must not carry the dead keel-* skill_actions "
10065
+ "path; keel-* skills are plugin-delivered."
10066
+ )
10067
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10068
+ if "delivered by the installed Keel plugin" not in cli:
10069
+ errors.append(
10070
+ "keel --help/--doctor must state that keel-* skills are delivered "
10071
+ "by the installed Keel plugin, not installed by the CLI."
10072
+ )
10073
+ if ".claude/skills/keel-*." in cli:
10074
+ errors.append(
10075
+ "keel --help must not claim the CLI installs skills under "
10076
+ ".claude/skills/keel-*."
10077
+ )
10078
+
10079
+
10080
+ def validate_openspec_invocable(errors: list[str]) -> None:
10081
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10082
+ if 'options.action === "openspec"' not in cli:
10083
+ errors.append("bin/keel.js must implement the `keel openspec` passthrough.")
10084
+ if cli.count("Invoke OpenSpec through") < 2:
10085
+ errors.append(
10086
+ "the apply and archive overlays must direct agents to `keel openspec` "
10087
+ "in place of a bare openspec command."
10088
+ )
10089
+
10090
+
10091
+ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
10092
+ cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
10093
+ if "--skip-specs" not in cli:
10094
+ errors.append(
10095
+ "the archive overlay must direct archive to pass --skip-specs "
10096
+ "after /opsx:sync has promoted the delta."
10097
+ )
10098
+ if "drop the change's guard manifest" not in cli:
10099
+ errors.append(
10100
+ "the archive overlay must remind the agent to run keel guard clear "
10101
+ "after archiving."
10102
+ )
10103
+ gates = (ROOT / "src/core/gates.js").read_text(encoding="utf-8")
10104
+ if "clearGuard" in gates:
10105
+ errors.append(
10106
+ "gates must stay read-only: gate code must not clear the guard "
10107
+ "manifest (guard clear is explicit via keel guard clear)."
10108
+ )
10109
+
10110
+
9803
10111
  def run_baseline() -> int:
9804
10112
  errors: list[str] = []
9805
10113
  validate_manifest(errors)
@@ -9808,6 +10116,10 @@ def run_baseline() -> int:
9808
10116
  validate_resident_blocks(errors)
9809
10117
  validate_templates(errors)
9810
10118
  validate_openspec_schema(errors)
10119
+ validate_review_status_single_source(errors)
10120
+ validate_install_honesty(errors)
10121
+ validate_openspec_invocable(errors)
10122
+ validate_archive_overlay_hygiene(errors)
9811
10123
  validate_skill_docs(errors)
9812
10124
  validate_skill_portability(ROOT, errors)
9813
10125
  validate_scripts_use_stdlib(errors)