@christang/keel 5.2.2 → 5.2.4

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.2.2"
41
- PROTOCOL_VERSION = "5.2.2"
40
+ PACKAGE_VERSION = "5.2.4"
41
+ PROTOCOL_VERSION = "5.2.4"
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")
@@ -3713,6 +3713,924 @@ def task_capsule_compact_fixture() -> str:
3713
3713
  )
3714
3714
 
3715
3715
 
3716
+ def validate_non_concrete_verify_diagnostic_scenario() -> int:
3717
+ """A compact v4 task whose Verify carries an unfilled token must be told so.
3718
+
3719
+ Regression for issue #7 example 1: the compact/expanded decision reads
3720
+ isConcrete(Verify), so one unfilled token used to select the expanded v3
3721
+ required-field set and report fields the author never declared.
3722
+ """
3723
+ v3_only_fields = (
3724
+ "Owner",
3725
+ "Read",
3726
+ "Commands",
3727
+ "Acceptance",
3728
+ "Candidate Boundary",
3729
+ "Report",
3730
+ )
3731
+ with tempfile.TemporaryDirectory(prefix="keel-non-concrete-verify-") as raw_tmp:
3732
+ repo = Path(raw_tmp)
3733
+ # A bare token in prose. The reporter's own case wrote it inside an
3734
+ # inline code span, which the inline-code-is-concrete scenario now
3735
+ # covers as legitimately filled; what must still be reported is a token
3736
+ # standing unfenced in the text.
3737
+ task = task_capsule_compact_fixture().replace(
3738
+ " - M1: node test.js\n",
3739
+ " - M1: node test.js writes ledger/scan-log/<date>.md\n",
3740
+ )
3741
+ write_text(repo / "openspec/changes/demo/tasks.md", task)
3742
+ started = run_keel(
3743
+ repo,
3744
+ "gate",
3745
+ "task-start",
3746
+ "--change",
3747
+ "demo",
3748
+ "--task",
3749
+ "1.1",
3750
+ "--json",
3751
+ )
3752
+ payload = json.loads(started.stdout)
3753
+ problems = payload.get("problems", [])
3754
+ codes = {problem.get("code") for problem in problems}
3755
+ if "non-concrete-verify" not in codes:
3756
+ report(
3757
+ "non-concrete-verify-diagnostic: an unfilled token in Verify did "
3758
+ "not produce the non-concrete-verify diagnostic."
3759
+ )
3760
+ report(started.stdout.strip())
3761
+ return 1
3762
+ named = [
3763
+ problem
3764
+ for problem in problems
3765
+ if problem.get("code") == "non-concrete-verify"
3766
+ and "<date>" in problem.get("message", "")
3767
+ ]
3768
+ if not named:
3769
+ report(
3770
+ "non-concrete-verify-diagnostic: the diagnostic did not name the "
3771
+ "matched token."
3772
+ )
3773
+ report(started.stdout.strip())
3774
+ return 1
3775
+ leaked = sorted(
3776
+ field
3777
+ for problem in problems
3778
+ if problem.get("code") == "missing-field"
3779
+ for field in v3_only_fields
3780
+ if problem.get("message", "").startswith(f"{field} must be concrete")
3781
+ )
3782
+ if leaked:
3783
+ report(
3784
+ "non-concrete-verify-diagnostic: expanded v3 fields were still "
3785
+ f"reported as missing: {', '.join(leaked)}."
3786
+ )
3787
+ report(started.stdout.strip())
3788
+ return 1
3789
+ # A task with no Verify at all is a genuine expanded v3 task and must
3790
+ # keep its existing required-field diagnostics.
3791
+ bare = task_capsule_compact_fixture()
3792
+ for block in (
3793
+ " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
3794
+ ):
3795
+ bare = bare.replace(block, "")
3796
+ write_text(repo / "openspec/changes/bare/tasks.md", bare)
3797
+ bare_started = run_keel(
3798
+ repo,
3799
+ "gate",
3800
+ "task-start",
3801
+ "--change",
3802
+ "bare",
3803
+ "--task",
3804
+ "1.1",
3805
+ "--json",
3806
+ )
3807
+ bare_payload = json.loads(bare_started.stdout)
3808
+ bare_codes = {
3809
+ problem.get("code") for problem in bare_payload.get("problems", [])
3810
+ }
3811
+ if "non-concrete-verify" in bare_codes:
3812
+ report(
3813
+ "non-concrete-verify-diagnostic: a task with no Verify was "
3814
+ "reported as carrying an unfilled token."
3815
+ )
3816
+ report(bare_started.stdout.strip())
3817
+ return 1
3818
+ if "non-concrete-verify-diagnostic" not in {name for name, _ in SCENARIOS}:
3819
+ report(
3820
+ "non-concrete-verify-diagnostic: the scenario registry does not "
3821
+ "include it."
3822
+ )
3823
+ return 1
3824
+ report("non-concrete-verify-diagnostic scenario passed.")
3825
+ return 0
3826
+
3827
+
3828
+ def validate_inline_code_is_concrete_scenario() -> int:
3829
+ """Unfilled-token forms inside inline code spans are documented patterns.
3830
+
3831
+ Regression for issue #7 example 1: the reporter's Verify wrote a filename
3832
+ pattern inside backticks and it was still judged unfilled. The exemption
3833
+ covers every token form, not only angle brackets, because prose naming the
3834
+ keywords is equally common — see
3835
+ keel/archive/follow-ups/2026-07-27-unfilled-token-keywords.md.
3836
+ """
3837
+ fenced_m1 = (
3838
+ " - M1: node test.js writes `ledger/scan-log/<date>.md`, skips a "
3839
+ "`TODO` marker, and leaves `TBD` rows alone\n"
3840
+ )
3841
+ bare_m1 = " - M1: node test.js writes ledger/scan-log/<date>.md\n"
3842
+ with tempfile.TemporaryDirectory(prefix="keel-inline-code-") as raw_tmp:
3843
+ repo = Path(raw_tmp)
3844
+ write_text(
3845
+ repo / "openspec/changes/fenced/tasks.md",
3846
+ task_capsule_compact_fixture().replace(
3847
+ " - M1: node test.js\n", fenced_m1
3848
+ ),
3849
+ )
3850
+ fenced = run_keel(
3851
+ repo, "gate", "task-start", "--change", "fenced", "--task", "1.1", "--json"
3852
+ )
3853
+ if fenced.returncode != 0:
3854
+ report(
3855
+ "inline-code-is-concrete: token forms inside inline code spans "
3856
+ "were still judged unfilled."
3857
+ )
3858
+ report((fenced.stdout or fenced.stderr).strip())
3859
+ return 1
3860
+ # The same token outside inline code must still be caught, otherwise the
3861
+ # exemption has swallowed the check it is narrowing.
3862
+ write_text(
3863
+ repo / "openspec/changes/bare/tasks.md",
3864
+ task_capsule_compact_fixture().replace(
3865
+ " - M1: node test.js\n", bare_m1
3866
+ ),
3867
+ )
3868
+ bare = run_keel(
3869
+ repo, "gate", "task-start", "--change", "bare", "--task", "1.1", "--json"
3870
+ )
3871
+ bare_codes = {
3872
+ problem.get("code")
3873
+ for problem in json.loads(bare.stdout).get("problems", [])
3874
+ }
3875
+ if "non-concrete-verify" not in bare_codes:
3876
+ report(
3877
+ "inline-code-is-concrete: a bare token outside inline code was "
3878
+ "no longer reported as unfilled."
3879
+ )
3880
+ report(bare.stdout.strip())
3881
+ return 1
3882
+ # Stripping runs after the emptiness test, so a field that is entirely
3883
+ # one code span must not read as empty.
3884
+ write_text(
3885
+ repo / "openspec/changes/whole/tasks.md",
3886
+ task_capsule_compact_fixture()
3887
+ .replace(" - M1: node test.js\n", " - M1: `node test.js`\n")
3888
+ .replace(" - src/feature.js\n", " - `src/feature.js`\n"),
3889
+ )
3890
+ whole = run_keel(
3891
+ repo, "gate", "task-start", "--change", "whole", "--task", "1.1", "--json"
3892
+ )
3893
+ if whole.returncode != 0:
3894
+ report(
3895
+ "inline-code-is-concrete: a field whose whole value is one "
3896
+ "inline code span was judged empty."
3897
+ )
3898
+ report((whole.stdout or whole.stderr).strip())
3899
+ return 1
3900
+ if "inline-code-is-concrete" not in {name for name, _ in SCENARIOS}:
3901
+ report("inline-code-is-concrete: the scenario registry does not include it.")
3902
+ return 1
3903
+ report("inline-code-is-concrete scenario passed.")
3904
+ return 0
3905
+
3906
+
3907
+ def validate_covers_separator_collision_scenario() -> int:
3908
+ """Issue #7 example 2: a requirement name containing the hierarchy separator.
3909
+
3910
+ Both spellings the reporter tried must fail loudly and say why. Keeping the
3911
+ slash over-segments the reference, which used to compile to an unlinked
3912
+ legacy-task-reference and PASS; removing it resolves nothing and used to
3913
+ give a generic message.
3914
+ """
3915
+ collide = "Continue or downgrade or switch/window criteria"
3916
+
3917
+ def spec(requirement: str) -> str:
3918
+ return (
3919
+ "# cap\n\n## Purpose\nDemo.\n\n"
3920
+ f"### Requirement: {requirement}\nText.\n\n"
3921
+ "#### Scenario: Criteria cover three outcomes\n"
3922
+ "- **WHEN** a thing\n- **THEN** another\n"
3923
+ )
3924
+
3925
+ def tasks(reference: str) -> str:
3926
+ return task_capsule_compact_fixture().replace(
3927
+ " - E1: Public behavior passes.\n", f" - {reference}\n"
3928
+ )
3929
+
3930
+ with tempfile.TemporaryDirectory(prefix="keel-covers-collision-") as raw_tmp:
3931
+ repo = Path(raw_tmp)
3932
+ write_text(repo / "openspec/specs/collide-cap/spec.md", spec(collide))
3933
+ write_text(repo / "openspec/specs/clean-cap/spec.md", spec("Plain name"))
3934
+
3935
+ def start(change: str, reference: str):
3936
+ write_text(repo / f"openspec/changes/{change}/tasks.md", tasks(reference))
3937
+ result = run_keel(
3938
+ repo, "gate", "task-start", "--change", change, "--task", "1.1",
3939
+ "--json",
3940
+ )
3941
+ return json.loads(result.stdout)
3942
+
3943
+ # The reporter's correct spelling: over-segmented, capability is real.
3944
+ kept = start(
3945
+ "kept",
3946
+ f"collide-cap / {collide} / Criteria cover three outcomes",
3947
+ )
3948
+ kept_messages = " ".join(
3949
+ problem.get("message", "") for problem in kept.get("problems", [])
3950
+ )
3951
+ kept_kinds = {
3952
+ entry.get("kind")
3953
+ for entry in kept.get("contract", {})
3954
+ .get("capsule", {})
3955
+ .get("authority", [])
3956
+ }
3957
+ if kept.get("status") != "fail" or "legacy-task-reference" in kept_kinds:
3958
+ report(
3959
+ "covers-separator-collision: an over-segmented reference to a "
3960
+ "real capability still degraded to a free-text reference."
3961
+ )
3962
+ report(json.dumps(kept.get("problems"), ensure_ascii=False))
3963
+ return 1
3964
+ if collide not in kept_messages or "separator" not in kept_messages:
3965
+ report(
3966
+ "covers-separator-collision: the over-segmented diagnostic did "
3967
+ "not name the colliding requirement."
3968
+ )
3969
+ report(kept_messages)
3970
+ return 1
3971
+ # The reporter's fallback spelling: resolves to nothing.
3972
+ trimmed = start(
3973
+ "trimmed",
3974
+ "collide-cap / Continue or downgrade or switchwindow criteria"
3975
+ " / Criteria cover three outcomes",
3976
+ )
3977
+ trimmed_messages = " ".join(
3978
+ problem.get("message", "") for problem in trimmed.get("problems", [])
3979
+ )
3980
+ if collide not in trimmed_messages:
3981
+ report(
3982
+ "covers-separator-collision: the unresolved diagnostic did not "
3983
+ "name the colliding requirement."
3984
+ )
3985
+ report(trimmed_messages)
3986
+ return 1
3987
+ # A capability with no collision keeps the plain wording.
3988
+ plain = start(
3989
+ "plain", "clean-cap / No such requirement / No such scenario"
3990
+ )
3991
+ plain_messages = " ".join(
3992
+ problem.get("message", "") for problem in plain.get("problems", [])
3993
+ )
3994
+ if "separator" in plain_messages:
3995
+ report(
3996
+ "covers-separator-collision: a capability with no colliding "
3997
+ "name still received the separator hint."
3998
+ )
3999
+ report(plain_messages)
4000
+ return 1
4001
+ # Free text that merely contains slashes is not a spec reference.
4002
+ free = start("free", "E1: writes a/b/c and passes")
4003
+ free_kinds = {
4004
+ entry.get("kind")
4005
+ for entry in free.get("contract", {})
4006
+ .get("capsule", {})
4007
+ .get("authority", [])
4008
+ }
4009
+ if free.get("status") != "pass" or free_kinds != {"legacy-task-reference"}:
4010
+ report(
4011
+ "covers-separator-collision: free text containing slashes was "
4012
+ "no longer accepted as a legacy reference."
4013
+ )
4014
+ report(json.dumps(free.get("problems"), ensure_ascii=False))
4015
+ return 1
4016
+ if "covers-separator-collision" not in {name for name, _ in SCENARIOS}:
4017
+ report(
4018
+ "covers-separator-collision: the scenario registry does not include it."
4019
+ )
4020
+ return 1
4021
+ report("covers-separator-collision scenario passed.")
4022
+ return 0
4023
+
4024
+
4025
+ def validate_unresolved_authority_names_field_scenario() -> int:
4026
+ """Issue #7 example 3: the diagnostic must name what it actually reads.
4027
+
4028
+ The check reads only the task's `Pre-authorized fallback:` line. The old
4029
+ wording said "documented design authority", which sent authors to design.md
4030
+ where the answer usually already was.
4031
+ """
4032
+ with tempfile.TemporaryDirectory(prefix="keel-unresolved-authority-") as raw:
4033
+ repo = Path(raw)
4034
+ # design.md documents Q1 and an authorized fallback in prose, which is
4035
+ # exactly the state the reporter was in when the message misdirected.
4036
+ write_text(
4037
+ repo / "openspec/changes/demo/design.md",
4038
+ "## Questions\n\nQ1 — Should the widget retry on timeout?\n\n"
4039
+ "Authorized fallback: retry twice with backoff, then stop.\n",
4040
+ )
4041
+ without = task_capsule_compact_fixture().replace(
4042
+ " - E1: Public behavior passes.\n", " - Q1\n"
4043
+ )
4044
+ write_text(repo / "openspec/changes/demo/tasks.md", without)
4045
+ result = run_keel(
4046
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
4047
+ )
4048
+ payload = json.loads(result.stdout)
4049
+ messages = [
4050
+ problem.get("message", "")
4051
+ for problem in payload.get("problems", [])
4052
+ if problem.get("code") == "unresolved-authority"
4053
+ ]
4054
+ if not messages:
4055
+ report(
4056
+ "unresolved-authority-names-field: a Q reference with no "
4057
+ "authorized fallback produced no unresolved-authority diagnostic."
4058
+ )
4059
+ report(result.stdout.strip())
4060
+ return 1
4061
+ message = messages[0]
4062
+ required = ("Autonomy boundary:", "Pre-authorized fallback:", "design.md")
4063
+ missing = [needle for needle in required if needle not in message]
4064
+ if missing:
4065
+ report(
4066
+ "unresolved-authority-names-field: the diagnostic omitted "
4067
+ f"{', '.join(missing)}."
4068
+ )
4069
+ report(message)
4070
+ return 1
4071
+ if "documented design authority" in message:
4072
+ report(
4073
+ "unresolved-authority-names-field: the diagnostic still points "
4074
+ "at design.md as the thing to add."
4075
+ )
4076
+ report(message)
4077
+ return 1
4078
+ # Doing literally what the message asks must clear it.
4079
+ with_fallback = without.replace(
4080
+ " - Pre-authorized fallback: none\n",
4081
+ " - Pre-authorized fallback: retry twice with backoff then stop;"
4082
+ " evidence is the retry log\n",
4083
+ )
4084
+ write_text(repo / "openspec/changes/fixed/tasks.md", with_fallback)
4085
+ write_text(
4086
+ repo / "openspec/changes/fixed/design.md",
4087
+ "## Questions\n\nQ1 — Should the widget retry on timeout?\n",
4088
+ )
4089
+ fixed = run_keel(
4090
+ repo, "gate", "task-start", "--change", "fixed", "--task", "1.1", "--json"
4091
+ )
4092
+ if fixed.returncode != 0:
4093
+ report(
4094
+ "unresolved-authority-names-field: following the diagnostic did "
4095
+ "not clear it."
4096
+ )
4097
+ report((fixed.stdout or fixed.stderr).strip())
4098
+ return 1
4099
+ if "unresolved-authority-names-field" not in {name for name, _ in SCENARIOS}:
4100
+ report(
4101
+ "unresolved-authority-names-field: the scenario registry does not "
4102
+ "include it."
4103
+ )
4104
+ return 1
4105
+ report("unresolved-authority-names-field scenario passed.")
4106
+ return 0
4107
+
4108
+
4109
+ def validate_dev_only_plugin_source_scoping_scenario() -> int:
4110
+ """Issue #6: plugins/keel/ exists only in Keel's own repository.
4111
+
4112
+ In a consuming project the source check is permanently `missing` and the
4113
+ next line told the author to install a plugin they had just installed.
4114
+ """
4115
+ with tempfile.TemporaryDirectory(prefix="keel-dev-only-scope-") as raw:
4116
+ consumer = Path(raw)
4117
+ init = run_keel(consumer, "--init", "--target", "claude")
4118
+ if init.returncode != 0:
4119
+ report("dev-only-plugin-source-scoping: keel --init failed.")
4120
+ report((init.stderr or init.stdout).strip())
4121
+ return 1
4122
+ doctor = run_keel(consumer, "--doctor")
4123
+ out = doctor.stdout or ""
4124
+ if "native plugin source" in out:
4125
+ report(
4126
+ "dev-only-plugin-source-scoping: a consuming project was still "
4127
+ "shown the development-only plugin source check."
4128
+ )
4129
+ report(out)
4130
+ return 1
4131
+ if "install the plugin if it is missing" in out:
4132
+ report(
4133
+ "dev-only-plugin-source-scoping: a consuming project was still "
4134
+ "told to install an already-installed plugin."
4135
+ )
4136
+ report(out)
4137
+ return 1
4138
+ if "plugin source" in out:
4139
+ report(
4140
+ "dev-only-plugin-source-scoping: the plugin source clause "
4141
+ "leaked into a consuming project's capability lines."
4142
+ )
4143
+ report(out)
4144
+ return 1
4145
+ # Keel's own repository must keep the check, which is where it means
4146
+ # something: the manifest and the CLI have to agree before release.
4147
+ own = run_keel(ROOT, "--doctor")
4148
+ if "native plugin source" not in (own.stdout or ""):
4149
+ report(
4150
+ "dev-only-plugin-source-scoping: Keel's own repository lost the "
4151
+ "plugin source check."
4152
+ )
4153
+ report((own.stdout or own.stderr).strip())
4154
+ return 1
4155
+ if "dev-only-plugin-source-scoping" not in {name for name, _ in SCENARIOS}:
4156
+ report(
4157
+ "dev-only-plugin-source-scoping: the scenario registry does not "
4158
+ "include it."
4159
+ )
4160
+ return 1
4161
+ report("dev-only-plugin-source-scoping scenario passed.")
4162
+ return 0
4163
+
4164
+
4165
+ def validate_source_repo_bootstrap_skip_scenario() -> int:
4166
+ """Issue #9: `keel --install` must not overwrite Keel's own AGENTS.md.
4167
+
4168
+ Keel's repository AGENTS.md carries the full protocol that four scenarios
4169
+ assert on; the packaged asset is the shorter consumer bootstrap. Writing it
4170
+ here drops those sections and turns the repository red.
4171
+ """
4172
+ managed = re.compile(
4173
+ r"<!--\s*keel:start.*?<!--\s*keel:end\s*-->", re.DOTALL
4174
+ )
4175
+
4176
+ def block(path: Path) -> str:
4177
+ found = managed.search(path.read_text(encoding="utf-8"))
4178
+ return found.group(0) if found else ""
4179
+
4180
+ own_agents = ROOT / "AGENTS.md"
4181
+ before = block(own_agents)
4182
+ if not before:
4183
+ report("source-repo-bootstrap-skip: Keel's AGENTS.md has no managed block.")
4184
+ return 1
4185
+ result = run_keel(ROOT, "--install", "--target", "claude")
4186
+ if result.returncode != 0:
4187
+ report("source-repo-bootstrap-skip: keel --install failed in Keel's repo.")
4188
+ report((result.stderr or result.stdout).strip())
4189
+ return 1
4190
+ if block(own_agents) != before:
4191
+ report(
4192
+ "source-repo-bootstrap-skip: keel --install rewrote Keel's own "
4193
+ "AGENTS.md managed block."
4194
+ )
4195
+ return 1
4196
+ if "skip AGENTS.md" not in (result.stdout or ""):
4197
+ report(
4198
+ "source-repo-bootstrap-skip: the skip was silent; it must be "
4199
+ "reported explicitly."
4200
+ )
4201
+ report((result.stdout or "").strip())
4202
+ return 1
4203
+ # A consuming project must still receive the bootstrap.
4204
+ with tempfile.TemporaryDirectory(prefix="keel-bootstrap-consumer-") as raw:
4205
+ consumer = Path(raw)
4206
+ installed = run_keel(consumer, "--install", "--target", "claude")
4207
+ if installed.returncode != 0:
4208
+ report(
4209
+ "source-repo-bootstrap-skip: keel --install failed in a "
4210
+ "consuming project."
4211
+ )
4212
+ report((installed.stderr or installed.stdout).strip())
4213
+ return 1
4214
+ asset_block = block(ROOT / "assets/bootstrap/AGENTS.md")
4215
+ if block(consumer / "AGENTS.md") != asset_block:
4216
+ report(
4217
+ "source-repo-bootstrap-skip: a consuming project did not "
4218
+ "receive the packaged bootstrap block."
4219
+ )
4220
+ return 1
4221
+ if "source-repo-bootstrap-skip" not in {name for name, _ in SCENARIOS}:
4222
+ report(
4223
+ "source-repo-bootstrap-skip: the scenario registry does not include it."
4224
+ )
4225
+ return 1
4226
+ report("source-repo-bootstrap-skip scenario passed.")
4227
+ return 0
4228
+
4229
+
4230
+ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
4231
+
4232
+
4233
+ def tracker_owner_tasks(findings: str, closure: str) -> str:
4234
+ """One complete, checked task plus one Expectation Coverage closure line."""
4235
+ return (
4236
+ "# Tasks\n\n"
4237
+ "## Expectation Coverage\n\n"
4238
+ "- E1:\n"
4239
+ f" - {closure}\n\n"
4240
+ "## 1. Work\n\n"
4241
+ "- [x] 1.1 Complete behavior\n"
4242
+ " - Owner: keel-agent\n"
4243
+ " - Mode: implementation\n"
4244
+ " - Covers:\n"
4245
+ " - E1: public behavior\n"
4246
+ " - Read:\n"
4247
+ " - README.md\n"
4248
+ " - Touch:\n"
4249
+ " - src/feature.js\n"
4250
+ " - Commands:\n"
4251
+ " - M1: node test.js\n"
4252
+ " - Acceptance:\n"
4253
+ " - Public behavior passes.\n"
4254
+ " - Autonomy boundary:\n"
4255
+ " - Default: hard-stop\n"
4256
+ " - Pre-authorized fallback: none\n"
4257
+ " - Coupling: none\n"
4258
+ " - Candidate Boundary:\n"
4259
+ " - One candidate.\n"
4260
+ " - Stop Rules:\n"
4261
+ " - Stop on failure.\n"
4262
+ " - Evidence:\n"
4263
+ " - M1: passed\n"
4264
+ " - Review:\n"
4265
+ " - Status: pass\n"
4266
+ " - Acceptance check: reviewed\n"
4267
+ " - Scope check: reviewed\n"
4268
+ f" - Findings: {findings}\n"
4269
+ " - Blocker: none\n"
4270
+ " - Stop if:\n"
4271
+ " - Scope expands.\n"
4272
+ " - Report:\n"
4273
+ " - Summary\n"
4274
+ )
4275
+
4276
+
4277
+ def validate_tracker_durable_owner_scenario() -> int:
4278
+ """Issue #12: an issue tracker is a durable follow-up owner.
4279
+
4280
+ The gate enumerated three repository-local forms, so a project whose
4281
+ declared follow-up owner is an issue tracker had to write a local note per
4282
+ finding whose only job was to be a shape the gate recognized. The tracker
4283
+ form is now accepted in both places a durable owner is required, and every
4284
+ previously accepted form still passes.
4285
+ """
4286
+ with tempfile.TemporaryDirectory(prefix="keel-tracker-owner-") as raw:
4287
+ repo = Path(raw)
4288
+ tasks = repo / "openspec/changes/demo/tasks.md"
4289
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
4290
+ write_text(
4291
+ repo / "openspec/changes/demo/specs/demo/spec.md",
4292
+ "## ADDED Requirements\n",
4293
+ )
4294
+
4295
+ def complete(findings: str, closure: str = "Covered by: 1.1"):
4296
+ write_text(tasks, tracker_owner_tasks(findings, closure))
4297
+ return run_keel(
4298
+ repo, "gate", "task-complete",
4299
+ "--change", "demo", "--task", "1.1", "--json",
4300
+ )
4301
+
4302
+ def close(closure: str):
4303
+ write_text(tasks, tracker_owner_tasks("none", closure))
4304
+ return run_keel(
4305
+ repo, "gate", "change-close",
4306
+ "--change", "demo", "--action", "sync", "--json",
4307
+ )
4308
+
4309
+ tracker = complete(f"stale local note; owner {TRACKER_OWNER}")
4310
+ if tracker.returncode != 0:
4311
+ report(
4312
+ "tracker-durable-owner: a finding owned by an absolute tracker "
4313
+ "reference was still refused."
4314
+ )
4315
+ report((tracker.stderr or tracker.stdout).strip())
4316
+ return 1
4317
+
4318
+ unowned = complete("stale local note that names no owner at all")
4319
+ unowned_payload = json.loads(unowned.stdout) if unowned.stdout else {}
4320
+ message = " ".join(
4321
+ problem.get("message", "")
4322
+ for problem in unowned_payload.get("problems", [])
4323
+ if problem.get("code") == "finding-owner"
4324
+ )
4325
+ if unowned.returncode != 3 or "http" not in message:
4326
+ report(
4327
+ "tracker-durable-owner: an unowned finding must still fail, and "
4328
+ "the error must list the tracker form among the accepted ones."
4329
+ )
4330
+ report((unowned.stderr or unowned.stdout).strip())
4331
+ return 1
4332
+
4333
+ handoff = complete("stale local note; owner keel/HANDOFF.md")
4334
+ if handoff.returncode != 3:
4335
+ report(
4336
+ "tracker-durable-owner: keel/HANDOFF.md must still be refused "
4337
+ "as a finding owner."
4338
+ )
4339
+ report((handoff.stderr or handoff.stdout).strip())
4340
+ return 1
4341
+
4342
+ archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
4343
+ if archived.returncode != 0:
4344
+ report(
4345
+ "tracker-durable-owner: the pre-existing keel/archive owner form "
4346
+ "regressed."
4347
+ )
4348
+ report((archived.stderr or archived.stdout).strip())
4349
+ return 1
4350
+
4351
+ closed_by_tracker = close(f"Durable owner: {TRACKER_OWNER}")
4352
+ if closed_by_tracker.returncode != 0:
4353
+ report(
4354
+ "tracker-durable-owner: change-close refused an Expectation "
4355
+ "Coverage durable owner naming a tracker reference."
4356
+ )
4357
+ report((closed_by_tracker.stderr or closed_by_tracker.stdout).strip())
4358
+ return 1
4359
+
4360
+ closed_by_task = close("Covered by: 1.1")
4361
+ if closed_by_task.returncode != 0:
4362
+ report(
4363
+ "tracker-durable-owner: the pre-existing Covered by closure "
4364
+ "regressed at change-close."
4365
+ )
4366
+ report((closed_by_task.stderr or closed_by_task.stdout).strip())
4367
+ return 1
4368
+
4369
+ closed_unowned = close("pending")
4370
+ if closed_unowned.returncode != 3:
4371
+ report(
4372
+ "tracker-durable-owner: change-close must still reject an E1 "
4373
+ "with no coverage, owner, or discard reason."
4374
+ )
4375
+ report((closed_unowned.stderr or closed_unowned.stdout).strip())
4376
+ return 1
4377
+
4378
+ if "tracker-durable-owner" not in {name for name, _ in SCENARIOS}:
4379
+ report("tracker-durable-owner: the scenario registry does not include it.")
4380
+ return 1
4381
+ report("tracker-durable-owner scenario passed.")
4382
+ return 0
4383
+
4384
+
4385
+ def validate_guard_manifest_ignored_scenario() -> int:
4386
+ """Issue #11: the guard manifest was written but declared ignorable nowhere.
4387
+
4388
+ Every gate run left an untracked `keel/guard.json` in the project, and
4389
+ because completion attributes working-tree paths against Touch, that is a
4390
+ permanent exception the author re-adjudicates at every completion.
4391
+ """
4392
+ with tempfile.TemporaryDirectory(prefix="keel-guard-ignore-") as raw:
4393
+ project = Path(raw)
4394
+ init = run_keel(project, "--install", "--target", "claude")
4395
+ if init.returncode != 0:
4396
+ report("guard-manifest-ignored: keel --install failed.")
4397
+ report((init.stderr or init.stdout).strip())
4398
+ return 1
4399
+ ignore_path = project / "keel/.gitignore"
4400
+ if not ignore_path.is_file():
4401
+ report(
4402
+ "guard-manifest-ignored: keel --install did not scaffold "
4403
+ "keel/.gitignore."
4404
+ )
4405
+ return 1
4406
+ declared = ignore_path.read_text(encoding="utf-8")
4407
+ if "guard.json" not in declared:
4408
+ report(
4409
+ "guard-manifest-ignored: the scaffolded keel/.gitignore does "
4410
+ "not declare the guard manifest."
4411
+ )
4412
+ report(declared)
4413
+ return 1
4414
+
4415
+ # git must actually honour the declaration: initialize a repository,
4416
+ # write a manifest through a passing task-start, and confirm the path
4417
+ # never appears in porcelain status.
4418
+ if subprocess.run(
4419
+ ["git", "init", "--quiet"], cwd=project, capture_output=True
4420
+ ).returncode != 0:
4421
+ report("guard-manifest-ignored: git init failed in the fixture.")
4422
+ return 1
4423
+ write_text(
4424
+ project / "openspec/changes/demo/tasks.md",
4425
+ tracker_owner_tasks("none", "Covered by: 1.1").replace(
4426
+ "- [x] 1.1", "- [ ] 1.1"
4427
+ ),
4428
+ )
4429
+ started = run_keel(
4430
+ project, "gate", "task-start",
4431
+ "--change", "demo", "--task", "1.1", "--json",
4432
+ )
4433
+ if started.returncode != 0 or not (project / "keel/guard.json").is_file():
4434
+ report(
4435
+ "guard-manifest-ignored: the fixture did not produce a guard "
4436
+ "manifest to test the declaration against."
4437
+ )
4438
+ report((started.stderr or started.stdout).strip())
4439
+ return 1
4440
+ status = subprocess.run(
4441
+ ["git", "status", "--short", "--untracked-files=all"],
4442
+ cwd=project, capture_output=True, encoding="utf-8",
4443
+ )
4444
+ if "guard.json" in (status.stdout or ""):
4445
+ report(
4446
+ "guard-manifest-ignored: git still reports the guard manifest "
4447
+ "after a gate run, so the declaration does not take effect."
4448
+ )
4449
+ report(status.stdout or "")
4450
+ return 1
4451
+
4452
+ # Scaffold once: a project's own file is never rewritten.
4453
+ own = "# mine\nguard.json\nscratch/\n"
4454
+ write_text(ignore_path, own)
4455
+ again = run_keel(project, "--install", "--target", "claude")
4456
+ if again.returncode != 0 or ignore_path.read_text(encoding="utf-8") != own:
4457
+ report(
4458
+ "guard-manifest-ignored: a second install overwrote the "
4459
+ "project's own keel/.gitignore."
4460
+ )
4461
+ report((again.stderr or again.stdout).strip())
4462
+ return 1
4463
+
4464
+ if not (ROOT / "keel/.gitignore").is_file():
4465
+ report(
4466
+ "guard-manifest-ignored: Keel's own repository does not declare "
4467
+ "the guard manifest ignorable."
4468
+ )
4469
+ return 1
4470
+ if "guard-manifest-ignored" not in {name for name, _ in SCENARIOS}:
4471
+ report("guard-manifest-ignored: the scenario registry does not include it.")
4472
+ return 1
4473
+ report("guard-manifest-ignored scenario passed.")
4474
+ return 0
4475
+
4476
+
4477
+ def validate_guard_status_is_not_enforcement_scenario() -> int:
4478
+ """Issue #14: `Guard: started` means the manifest was written.
4479
+
4480
+ Authors read it as "writes are being checked now". The two came apart when a
4481
+ session kept plugin state that predated a marketplace switch: the manifest
4482
+ was valid and keel's PreToolUse hook never ran. Keel cannot observe the hook
4483
+ from the repository, so the result must say so rather than imply otherwise.
4484
+ """
4485
+ needles = ("enforcement", "runtime hook", "cannot observe")
4486
+ # Assertive claims only: the honest sentence legitimately denies that a
4487
+ # written manifest proves anything, so a bare "writes are checked" substring
4488
+ # would match the denial itself.
4489
+ forbidden = (
4490
+ "enforcement is active",
4491
+ "enforcement is live",
4492
+ "writes are guarded",
4493
+ )
4494
+ with tempfile.TemporaryDirectory(prefix="keel-guard-honesty-") as raw:
4495
+ project = Path(raw)
4496
+ write_text(
4497
+ project / "openspec/changes/demo/tasks.md",
4498
+ tracker_owner_tasks("none", "Covered by: 1.1").replace(
4499
+ "- [x] 1.1", "- [ ] 1.1"
4500
+ ),
4501
+ )
4502
+ started = run_keel(
4503
+ project, "guard", "start",
4504
+ "--change", "demo", "--task", "1.1", "--json",
4505
+ )
4506
+ if started.returncode != 0:
4507
+ report("guard-status-is-not-enforcement: keel guard start failed.")
4508
+ report((started.stderr or started.stdout).strip())
4509
+ return 1
4510
+ for label, result in (
4511
+ ("start", started),
4512
+ (
4513
+ "status",
4514
+ run_keel(project, "guard", "status", "--json"),
4515
+ ),
4516
+ ):
4517
+ payload = json.loads(result.stdout) if result.stdout else {}
4518
+ warnings = " ".join(payload.get("warnings", []))
4519
+ missing = [needle for needle in needles if needle not in warnings]
4520
+ if missing:
4521
+ report(
4522
+ f"guard-status-is-not-enforcement: keel guard {label} does "
4523
+ "not state that the status describes the manifest and that "
4524
+ "enforcement depends on a runtime hook Keel cannot observe; "
4525
+ f"missing {missing}."
4526
+ )
4527
+ report(result.stdout or result.stderr or "")
4528
+ return 1
4529
+ if not payload.get("status"):
4530
+ report(
4531
+ f"guard-status-is-not-enforcement: keel guard {label} lost "
4532
+ "its status value."
4533
+ )
4534
+ return 1
4535
+ human = run_keel(
4536
+ project,
4537
+ "guard",
4538
+ *(("start", "--change", "demo", "--task", "1.1", "--force")
4539
+ if label == "start" else ("status",)),
4540
+ )
4541
+ if "runtime hook" not in (human.stdout or ""):
4542
+ report(
4543
+ f"guard-status-is-not-enforcement: the human-readable keel "
4544
+ f"guard {label} output omits the enforcement boundary."
4545
+ )
4546
+ report(human.stdout or human.stderr or "")
4547
+ return 1
4548
+ for phrase in forbidden:
4549
+ if phrase in warnings or phrase in (human.stdout or ""):
4550
+ report(
4551
+ f"guard-status-is-not-enforcement: keel guard {label} "
4552
+ f"asserts observed enforcement: {phrase!r}."
4553
+ )
4554
+ return 1
4555
+ # The pre-existing durability statement must survive alongside it.
4556
+ status_payload = json.loads(
4557
+ run_keel(project, "guard", "status", "--json").stdout
4558
+ )
4559
+ if not any(
4560
+ "durable authority" in warning
4561
+ for warning in status_payload.get("warnings", [])
4562
+ ):
4563
+ report(
4564
+ "guard-status-is-not-enforcement: the existing durable-authority "
4565
+ "statement was dropped."
4566
+ )
4567
+ return 1
4568
+ if "guard-status-is-not-enforcement" not in {name for name, _ in SCENARIOS}:
4569
+ report(
4570
+ "guard-status-is-not-enforcement: the scenario registry does not "
4571
+ "include it."
4572
+ )
4573
+ return 1
4574
+ report("guard-status-is-not-enforcement scenario passed.")
4575
+ return 0
4576
+
4577
+
4578
+ def validate_source_repo_cli_resolution_scenario() -> int:
4579
+ """Issue #13 item 3: a bare `keel` runs the installed package.
4580
+
4581
+ When the repository under change *is* Keel, gate commands verify the
4582
+ installed CLI rather than the working tree, and the failure mode is a
4583
+ silently stale result rather than an error. One dogfood round was lost to
4584
+ two spurious problems reported by a stale global CLI.
4585
+ """
4586
+ own = run_keel(ROOT, "--doctor")
4587
+ out = own.stdout or ""
4588
+ if "node bin/keel.js" not in out:
4589
+ report(
4590
+ "source-repo-cli-resolution: Keel's own repository is not told to "
4591
+ "run gate commands through its own entry point."
4592
+ )
4593
+ report(out)
4594
+ return 1
4595
+ # The local entry point needs an explicit repository argument; an author
4596
+ # who copies the hint without it gets an unrelated failure.
4597
+ hint = next(
4598
+ (line for line in out.splitlines() if "node bin/keel.js" in line), ""
4599
+ )
4600
+ # The line must be advisory: it describes a hazard, not a repository
4601
+ # failure, so it must never push doctor toward a non-ok verdict.
4602
+ for needle in ("gate", ".", "advisory"):
4603
+ if needle not in hint:
4604
+ report(
4605
+ "source-repo-cli-resolution: the hint does not name a usable "
4606
+ f"invocation, or is not advisory; missing {needle!r} in: {hint}"
4607
+ )
4608
+ return 1
4609
+ with tempfile.TemporaryDirectory(prefix="keel-cli-resolution-") as raw:
4610
+ consumer = Path(raw)
4611
+ init = run_keel(consumer, "--init", "--target", "claude")
4612
+ if init.returncode != 0:
4613
+ report("source-repo-cli-resolution: keel --init failed.")
4614
+ report((init.stderr or init.stdout).strip())
4615
+ return 1
4616
+ consumer_doctor = run_keel(consumer, "--doctor")
4617
+ if "node bin/keel.js" in (consumer_doctor.stdout or ""):
4618
+ report(
4619
+ "source-repo-cli-resolution: a consuming project was shown a "
4620
+ "hazard that exists only in Keel's own repository."
4621
+ )
4622
+ report(consumer_doctor.stdout or "")
4623
+ return 1
4624
+ if "source-repo-cli-resolution" not in {name for name, _ in SCENARIOS}:
4625
+ report(
4626
+ "source-repo-cli-resolution: the scenario registry does not "
4627
+ "include it."
4628
+ )
4629
+ return 1
4630
+ report("source-repo-cli-resolution scenario passed.")
4631
+ return 0
4632
+
4633
+
3716
4634
  def validate_task_capsule_scenario() -> int:
3717
4635
  with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
3718
4636
  repo = Path(raw_tmp)
@@ -4887,14 +5805,19 @@ def validate_core_gates_scenario() -> int:
4887
5805
  (completion_repo / "openspec/changes/other/proposal.md").unlink()
4888
5806
  (completion_repo / "openspec/specs/demo-spec/spec.md").unlink()
4889
5807
 
4890
- # Explicit --record replaces exactly the selected task's literal
4891
- # pending Contract anchor; a non-pending or missing anchor refuses
4892
- # loudly writing nothing; without the flag the gate stays read-only.
5808
+ # Explicit --record replaces the selected task's Contract anchor
5809
+ # whatever it currently holds, so reauthorizing a task whose authority
5810
+ # changed needs no manual edit; a no-op re-record writes nothing, only
5811
+ # a missing anchor refuses, and without the flag the gate stays
5812
+ # read-only.
4893
5813
  record_repo = root / "record-anchor"
4894
5814
  record_repo.mkdir()
4895
5815
  record_tasks = record_repo / "openspec/changes/demo/tasks.md"
4896
5816
 
4897
- def record_task(anchor: str) -> str:
5817
+ def record_task(anchor: str, extra_touch: bool = False) -> str:
5818
+ touch = " - src/feature.js\n"
5819
+ if extra_touch:
5820
+ touch += " - src/extra.js\n"
4898
5821
  return (
4899
5822
  "# Tasks\n\n"
4900
5823
  "- [ ] 1.1 Record behavior\n"
@@ -4905,8 +5828,8 @@ def validate_core_gates_scenario() -> int:
4905
5828
  " - Read:\n"
4906
5829
  " - README.md\n"
4907
5830
  " - Touch:\n"
4908
- " - src/feature.js\n"
4909
- " - openspec/changes/demo/tasks.md\n"
5831
+ + touch
5832
+ + " - openspec/changes/demo/tasks.md\n"
4910
5833
  " - Commands:\n"
4911
5834
  " - M1: node test.js\n"
4912
5835
  " - Acceptance:\n"
@@ -4951,8 +5874,15 @@ def validate_core_gates_scenario() -> int:
4951
5874
  report("core-gates scenario --record on a pending anchor failed.")
4952
5875
  report((recorded.stderr or recorded.stdout).strip())
4953
5876
  return 1
5877
+ recorded_result = json.loads(recorded.stdout)
5878
+ if recorded_result.get("record", {}).get("status") != "recorded":
5879
+ report(
5880
+ "core-gates scenario --record over a pending anchor must "
5881
+ "report the outcome as recorded."
5882
+ )
5883
+ return 1
4954
5884
  fingerprint = (
4955
- json.loads(recorded.stdout)
5885
+ recorded_result
4956
5886
  .get("contract", {})
4957
5887
  .get("fingerprint", {})
4958
5888
  .get("value", "")
@@ -4993,23 +5923,67 @@ def validate_core_gates_scenario() -> int:
4993
5923
  )
4994
5924
  return 1
4995
5925
 
4996
- before_refusal = record_tasks.read_bytes()
5926
+ before_noop = record_tasks.read_bytes()
5927
+ noop = run_keel(
5928
+ record_repo, "gate", "task-start",
5929
+ "--change", "demo", "--task", "1.1", "--no-guard", "--record",
5930
+ "--json",
5931
+ )
5932
+ noop_result = json.loads(noop.stdout) if noop.stdout else {}
5933
+ if (
5934
+ noop.returncode != 0
5935
+ or noop_result.get("record", {}).get("status") != "unchanged"
5936
+ or noop_result.get("warnings")
5937
+ or record_tasks.read_bytes() != before_noop
5938
+ ):
5939
+ report(
5940
+ "core-gates scenario --record over an anchor that already "
5941
+ "carries the compiled fingerprint must report unchanged, "
5942
+ "warn about nothing, and write nothing."
5943
+ )
5944
+ report((noop.stderr or noop.stdout).strip())
5945
+ return 1
5946
+
5947
+ # Reauthorization: the task authority changes, so the recorded anchor
5948
+ # is now stale and --record must replace it rather than refuse.
5949
+ anchor_line = f"keel-task-capsule/v1 sha256:{fingerprint}"
5950
+ write_text(record_tasks, record_task(anchor_line, extra_touch=True))
5951
+ before_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
4997
5952
  rerecord = run_keel(
4998
5953
  record_repo, "gate", "task-start",
4999
5954
  "--change", "demo", "--task", "1.1", "--no-guard", "--record",
5000
5955
  "--json",
5001
5956
  )
5957
+ rerecord_result = json.loads(rerecord.stdout) if rerecord.stdout else {}
5958
+ new_fingerprint = (
5959
+ rerecord_result.get("contract", {}).get("fingerprint", {}).get("value", "")
5960
+ )
5961
+ after_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
5962
+ rerecord_changed = [
5963
+ (old, new)
5964
+ for old, new in zip(before_rerecord, after_rerecord)
5965
+ if old != new
5966
+ ]
5002
5967
  if (
5003
- rerecord.returncode != 3
5968
+ rerecord.returncode != 0
5969
+ or rerecord_result.get("record", {}).get("status") != "rerecorded"
5970
+ or fingerprint not in rerecord_result.get("record", {}).get("previous", "")
5971
+ or not new_fingerprint
5972
+ or new_fingerprint == fingerprint
5973
+ or len(before_rerecord) != len(after_rerecord)
5974
+ or len(rerecord_changed) != 1
5975
+ or rerecord_changed[0][1]
5976
+ != f" - Contract: keel-task-capsule/v1 sha256:{new_fingerprint}"
5004
5977
  or not any(
5005
- problem.get("code") == "record-refused"
5006
- for problem in json.loads(rerecord.stdout).get("problems", [])
5978
+ fingerprint in warning
5979
+ for warning in rerecord_result.get("warnings", [])
5007
5980
  )
5008
- or record_tasks.read_bytes() != before_refusal
5009
5981
  ):
5010
5982
  report(
5011
- "core-gates scenario --record on an already-recorded anchor "
5012
- "must refuse deterministically and write nothing."
5983
+ "core-gates scenario --record over a stale recorded anchor "
5984
+ "must replace exactly that line with the new fingerprint, "
5985
+ "report the outcome as rerecorded with the replaced value, "
5986
+ "and warn naming the fingerprint it replaced."
5013
5987
  )
5014
5988
  report((rerecord.stderr or rerecord.stdout).strip())
5015
5989
  return 1
@@ -9110,12 +10084,16 @@ def validate_thin_native_install_scenario() -> int:
9110
10084
  report(uncertain_text.strip())
9111
10085
  return 1
9112
10086
 
9113
- # --- Case E: doctor reports the missing native plugin with remediation ---
10087
+ # --- Case E: doctor reports the native plugin runtime with remediation ---
10088
+ # These repos consume Keel, so the runtime line and its install
10089
+ # remediation must appear, while the plugin *source* check must not:
10090
+ # plugins/keel/ exists only in Keel's own repository and `keel --init`
10091
+ # never creates it, so reporting it here is a permanent unactionable
10092
+ # `missing`. See the dev-only-plugin-source-scoping scenario.
9114
10093
  doctor = run_keel(repo, "--doctor")
9115
10094
  doctor_text = (doctor.stdout or "") + (doctor.stderr or "")
9116
10095
  if (
9117
- "native plugin source" not in doctor_text
9118
- or "native plugin runtime" not in doctor_text
10096
+ "native plugin runtime" not in doctor_text
9119
10097
  or "keel@<marketplace>" not in doctor_text
9120
10098
  ):
9121
10099
  report(
@@ -9124,14 +10102,21 @@ def validate_thin_native_install_scenario() -> int:
9124
10102
  )
9125
10103
  report(doctor_text.strip())
9126
10104
  return 1
10105
+ if "native plugin source" in doctor_text:
10106
+ report(
10107
+ "thin-native-install doctor reported the development-only "
10108
+ "plugin source check in a consuming project."
10109
+ )
10110
+ report(doctor_text.strip())
10111
+ return 1
9127
10112
  missing_repo = tmp / "no-plugin"
9128
10113
  missing_repo.mkdir()
9129
10114
  missing_doctor = run_keel(missing_repo, "--doctor")
9130
10115
  missing_text = (missing_doctor.stdout or "") + (missing_doctor.stderr or "")
9131
- if "plugin source absent" not in missing_text:
10116
+ if "plugin source" in missing_text:
9132
10117
  report(
9133
- "thin-native-install doctor does not diagnose an absent native "
9134
- "plugin source."
10118
+ "thin-native-install doctor diagnosed a plugin source outside "
10119
+ "Keel's own repository."
9135
10120
  )
9136
10121
  report(missing_text.strip())
9137
10122
  return 1
@@ -10400,6 +11385,31 @@ SCENARIOS: tuple = (
10400
11385
  ("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
10401
11386
  ("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
10402
11387
  ("verify-layer-tag", validate_verify_layer_tag_scenario),
11388
+ (
11389
+ "non-concrete-verify-diagnostic",
11390
+ validate_non_concrete_verify_diagnostic_scenario,
11391
+ ),
11392
+ ("inline-code-is-concrete", validate_inline_code_is_concrete_scenario),
11393
+ ("covers-separator-collision", validate_covers_separator_collision_scenario),
11394
+ (
11395
+ "unresolved-authority-names-field",
11396
+ validate_unresolved_authority_names_field_scenario,
11397
+ ),
11398
+ (
11399
+ "dev-only-plugin-source-scoping",
11400
+ validate_dev_only_plugin_source_scoping_scenario,
11401
+ ),
11402
+ (
11403
+ "source-repo-bootstrap-skip",
11404
+ validate_source_repo_bootstrap_skip_scenario,
11405
+ ),
11406
+ ("tracker-durable-owner", validate_tracker_durable_owner_scenario),
11407
+ ("guard-manifest-ignored", validate_guard_manifest_ignored_scenario),
11408
+ (
11409
+ "guard-status-is-not-enforcement",
11410
+ validate_guard_status_is_not_enforcement_scenario,
11411
+ ),
11412
+ ("source-repo-cli-resolution", validate_source_repo_cli_resolution_scenario),
10403
11413
  ("task-contract-core", validate_task_contract_core_scenario),
10404
11414
  ("task-capsule", validate_task_capsule_scenario),
10405
11415
  ("task-verification-strategies", validate_task_verification_strategies_scenario),