@christang/keel 5.2.1 → 5.2.3

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.1"
41
- PROTOCOL_VERSION = "5.2.1"
40
+ PACKAGE_VERSION = "5.2.3"
41
+ PROTOCOL_VERSION = "5.2.3"
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,520 @@ 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
+
3716
4230
  def validate_task_capsule_scenario() -> int:
3717
4231
  with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
3718
4232
  repo = Path(raw_tmp)
@@ -4887,14 +5401,19 @@ def validate_core_gates_scenario() -> int:
4887
5401
  (completion_repo / "openspec/changes/other/proposal.md").unlink()
4888
5402
  (completion_repo / "openspec/specs/demo-spec/spec.md").unlink()
4889
5403
 
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.
5404
+ # Explicit --record replaces the selected task's Contract anchor
5405
+ # whatever it currently holds, so reauthorizing a task whose authority
5406
+ # changed needs no manual edit; a no-op re-record writes nothing, only
5407
+ # a missing anchor refuses, and without the flag the gate stays
5408
+ # read-only.
4893
5409
  record_repo = root / "record-anchor"
4894
5410
  record_repo.mkdir()
4895
5411
  record_tasks = record_repo / "openspec/changes/demo/tasks.md"
4896
5412
 
4897
- def record_task(anchor: str) -> str:
5413
+ def record_task(anchor: str, extra_touch: bool = False) -> str:
5414
+ touch = " - src/feature.js\n"
5415
+ if extra_touch:
5416
+ touch += " - src/extra.js\n"
4898
5417
  return (
4899
5418
  "# Tasks\n\n"
4900
5419
  "- [ ] 1.1 Record behavior\n"
@@ -4905,8 +5424,8 @@ def validate_core_gates_scenario() -> int:
4905
5424
  " - Read:\n"
4906
5425
  " - README.md\n"
4907
5426
  " - Touch:\n"
4908
- " - src/feature.js\n"
4909
- " - openspec/changes/demo/tasks.md\n"
5427
+ + touch
5428
+ + " - openspec/changes/demo/tasks.md\n"
4910
5429
  " - Commands:\n"
4911
5430
  " - M1: node test.js\n"
4912
5431
  " - Acceptance:\n"
@@ -4951,8 +5470,15 @@ def validate_core_gates_scenario() -> int:
4951
5470
  report("core-gates scenario --record on a pending anchor failed.")
4952
5471
  report((recorded.stderr or recorded.stdout).strip())
4953
5472
  return 1
5473
+ recorded_result = json.loads(recorded.stdout)
5474
+ if recorded_result.get("record", {}).get("status") != "recorded":
5475
+ report(
5476
+ "core-gates scenario --record over a pending anchor must "
5477
+ "report the outcome as recorded."
5478
+ )
5479
+ return 1
4954
5480
  fingerprint = (
4955
- json.loads(recorded.stdout)
5481
+ recorded_result
4956
5482
  .get("contract", {})
4957
5483
  .get("fingerprint", {})
4958
5484
  .get("value", "")
@@ -4993,23 +5519,67 @@ def validate_core_gates_scenario() -> int:
4993
5519
  )
4994
5520
  return 1
4995
5521
 
4996
- before_refusal = record_tasks.read_bytes()
5522
+ before_noop = record_tasks.read_bytes()
5523
+ noop = run_keel(
5524
+ record_repo, "gate", "task-start",
5525
+ "--change", "demo", "--task", "1.1", "--no-guard", "--record",
5526
+ "--json",
5527
+ )
5528
+ noop_result = json.loads(noop.stdout) if noop.stdout else {}
5529
+ if (
5530
+ noop.returncode != 0
5531
+ or noop_result.get("record", {}).get("status") != "unchanged"
5532
+ or noop_result.get("warnings")
5533
+ or record_tasks.read_bytes() != before_noop
5534
+ ):
5535
+ report(
5536
+ "core-gates scenario --record over an anchor that already "
5537
+ "carries the compiled fingerprint must report unchanged, "
5538
+ "warn about nothing, and write nothing."
5539
+ )
5540
+ report((noop.stderr or noop.stdout).strip())
5541
+ return 1
5542
+
5543
+ # Reauthorization: the task authority changes, so the recorded anchor
5544
+ # is now stale and --record must replace it rather than refuse.
5545
+ anchor_line = f"keel-task-capsule/v1 sha256:{fingerprint}"
5546
+ write_text(record_tasks, record_task(anchor_line, extra_touch=True))
5547
+ before_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
4997
5548
  rerecord = run_keel(
4998
5549
  record_repo, "gate", "task-start",
4999
5550
  "--change", "demo", "--task", "1.1", "--no-guard", "--record",
5000
5551
  "--json",
5001
5552
  )
5553
+ rerecord_result = json.loads(rerecord.stdout) if rerecord.stdout else {}
5554
+ new_fingerprint = (
5555
+ rerecord_result.get("contract", {}).get("fingerprint", {}).get("value", "")
5556
+ )
5557
+ after_rerecord = record_tasks.read_text(encoding="utf-8").splitlines()
5558
+ rerecord_changed = [
5559
+ (old, new)
5560
+ for old, new in zip(before_rerecord, after_rerecord)
5561
+ if old != new
5562
+ ]
5002
5563
  if (
5003
- rerecord.returncode != 3
5564
+ rerecord.returncode != 0
5565
+ or rerecord_result.get("record", {}).get("status") != "rerecorded"
5566
+ or fingerprint not in rerecord_result.get("record", {}).get("previous", "")
5567
+ or not new_fingerprint
5568
+ or new_fingerprint == fingerprint
5569
+ or len(before_rerecord) != len(after_rerecord)
5570
+ or len(rerecord_changed) != 1
5571
+ or rerecord_changed[0][1]
5572
+ != f" - Contract: keel-task-capsule/v1 sha256:{new_fingerprint}"
5004
5573
  or not any(
5005
- problem.get("code") == "record-refused"
5006
- for problem in json.loads(rerecord.stdout).get("problems", [])
5574
+ fingerprint in warning
5575
+ for warning in rerecord_result.get("warnings", [])
5007
5576
  )
5008
- or record_tasks.read_bytes() != before_refusal
5009
5577
  ):
5010
5578
  report(
5011
- "core-gates scenario --record on an already-recorded anchor "
5012
- "must refuse deterministically and write nothing."
5579
+ "core-gates scenario --record over a stale recorded anchor "
5580
+ "must replace exactly that line with the new fingerprint, "
5581
+ "report the outcome as rerecorded with the replaced value, "
5582
+ "and warn naming the fingerprint it replaced."
5013
5583
  )
5014
5584
  report((rerecord.stderr or rerecord.stdout).strip())
5015
5585
  return 1
@@ -7687,6 +8257,297 @@ def validate_native_tasks_view_scenario() -> int:
7687
8257
  return 0
7688
8258
 
7689
8259
 
8260
+ def validate_verification_layering_docs_scenario() -> int:
8261
+ en = (ROOT / "README.md").read_text(encoding="utf-8")
8262
+ for needle in (
8263
+ "## Verification layering",
8264
+ "inner-loop",
8265
+ "Full gate",
8266
+ "pre-push",
8267
+ "change-close",
8268
+ ):
8269
+ if needle not in en:
8270
+ report(
8271
+ "verification-layering-docs: README.md lacks the fast/full "
8272
+ f"verification split marker: {needle}"
8273
+ )
8274
+ return 1
8275
+
8276
+ zh = (ROOT / "README.zh-CN.md").read_text(encoding="utf-8")
8277
+ for needle in ("## 验证分层", "快速内环", "全量门禁", "pre-push", "change-close"):
8278
+ if needle not in zh:
8279
+ report(
8280
+ "verification-layering-docs: README.zh-CN.md lacks the fast/full "
8281
+ f"verification split marker: {needle}"
8282
+ )
8283
+ return 1
8284
+
8285
+ if "verification-layering-docs" not in {name for name, _ in SCENARIOS}:
8286
+ report("verification-layering-docs: the scenario registry does not include it.")
8287
+ return 1
8288
+
8289
+ report("verification-layering-docs scenario passed.")
8290
+ return 0
8291
+
8292
+
8293
+ def validate_fast_check_config_scaffold_scenario() -> int:
8294
+ with tempfile.TemporaryDirectory(prefix="keel-fastcfg-") as raw_tmp:
8295
+ repo = Path(raw_tmp)
8296
+ first = run_keel(repo, "--install")
8297
+ if first.returncode != 0:
8298
+ report("fast-check-config-scaffold: keel --install failed.")
8299
+ report((first.stderr or first.stdout).strip())
8300
+ return 1
8301
+
8302
+ config_path = repo / "keel" / "config.yaml"
8303
+ if not config_path.is_file():
8304
+ report("fast-check-config-scaffold: install did not scaffold keel/config.yaml.")
8305
+ return 1
8306
+ scaffolded = config_path.read_text(encoding="utf-8")
8307
+ for needle in ("fast_check", "keel gate change-close", "--with-git-hooks"):
8308
+ if needle not in scaffolded:
8309
+ report(
8310
+ "fast-check-config-scaffold: scaffolded keel/config.yaml lacks the "
8311
+ f"fast_check guidance marker: {needle}"
8312
+ )
8313
+ return 1
8314
+
8315
+ # A project's own edits to keel/config.yaml must survive re-install.
8316
+ edited = "fast_check: pytest -m 'not slow' -q\n"
8317
+ config_path.write_text(edited, encoding="utf-8")
8318
+ second = run_keel(repo, "--install")
8319
+ if second.returncode != 0:
8320
+ report("fast-check-config-scaffold: second keel --install failed.")
8321
+ report((second.stderr or second.stdout).strip())
8322
+ return 1
8323
+ if config_path.read_text(encoding="utf-8") != edited:
8324
+ report(
8325
+ "fast-check-config-scaffold: re-install overwrote an existing "
8326
+ "keel/config.yaml."
8327
+ )
8328
+ return 1
8329
+
8330
+ report("fast-check-config-scaffold scenario passed.")
8331
+ return 0
8332
+
8333
+
8334
+ def validate_fast_pre_push_hooks_scenario() -> int:
8335
+ def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
8336
+ return subprocess.run(
8337
+ ["git", "-C", str(repo), *args], capture_output=True, text=True
8338
+ )
8339
+
8340
+ def init_repo(root: Path, name: str) -> Path:
8341
+ repo = root / name
8342
+ repo.mkdir()
8343
+ git(repo, "init", "-q")
8344
+ git(repo, "config", "user.email", "t@example.com")
8345
+ git(repo, "config", "user.name", "keel-test")
8346
+ return repo
8347
+
8348
+ def declare_fast_check(repo: Path, command: str) -> None:
8349
+ (repo / "keel").mkdir(exist_ok=True)
8350
+ (repo / "keel" / "config.yaml").write_text(
8351
+ f"fast_check: {command}\n", encoding="utf-8"
8352
+ )
8353
+
8354
+ def hooks_path(repo: Path) -> str | None:
8355
+ got = git(repo, "config", "--local", "--get", "core.hooksPath")
8356
+ return got.stdout.strip() if got.returncode == 0 else None
8357
+
8358
+ with tempfile.TemporaryDirectory(prefix="keel-prepush-") as raw_tmp:
8359
+ root = Path(raw_tmp)
8360
+
8361
+ # 1. A declared fast_check generates the hook and sets hooksPath.
8362
+ declared = init_repo(root, "declared")
8363
+ declare_fast_check(declared, "echo fast-check-ran")
8364
+ res = run_keel(declared, "--install", "--with-git-hooks")
8365
+ if res.returncode != 0:
8366
+ report("fast-pre-push-hooks: --with-git-hooks failed with a declared fast_check.")
8367
+ report((res.stderr or res.stdout).strip())
8368
+ return 1
8369
+ hook = declared / ".githooks" / "pre-push"
8370
+ if not hook.is_file():
8371
+ report("fast-pre-push-hooks: --with-git-hooks did not write .githooks/pre-push.")
8372
+ return 1
8373
+ hook_text = hook.read_text(encoding="utf-8")
8374
+ if not hook_text.startswith("#!/bin/sh") or "echo fast-check-ran" not in hook_text:
8375
+ report("fast-pre-push-hooks: pre-push does not run the declared fast_check under sh.")
8376
+ report(hook_text)
8377
+ return 1
8378
+ if hooks_path(declared) != ".githooks":
8379
+ report("fast-pre-push-hooks: --with-git-hooks did not set core.hooksPath to .githooks.")
8380
+ return 1
8381
+
8382
+ # 2. A plain install touches neither the hook nor git config.
8383
+ plain = init_repo(root, "plain")
8384
+ declare_fast_check(plain, "echo plain")
8385
+ if run_keel(plain, "--install").returncode != 0:
8386
+ report("fast-pre-push-hooks: plain install failed.")
8387
+ return 1
8388
+ if (plain / ".githooks" / "pre-push").exists():
8389
+ report("fast-pre-push-hooks: plain install wrote a pre-push hook.")
8390
+ return 1
8391
+ if hooks_path(plain) is not None:
8392
+ report("fast-pre-push-hooks: plain install set core.hooksPath.")
8393
+ return 1
8394
+
8395
+ # 3. Without a declared fast_check the flag refuses and writes nothing.
8396
+ undeclared = init_repo(root, "undeclared")
8397
+ res = run_keel(undeclared, "--install", "--with-git-hooks")
8398
+ if res.returncode == 0:
8399
+ report("fast-pre-push-hooks: --with-git-hooks did not refuse without a fast_check.")
8400
+ return 1
8401
+ if (undeclared / ".githooks" / "pre-push").exists():
8402
+ report("fast-pre-push-hooks: a refusal still wrote a pre-push hook.")
8403
+ return 1
8404
+ if hooks_path(undeclared) is not None:
8405
+ report("fast-pre-push-hooks: a refusal still set core.hooksPath.")
8406
+ return 1
8407
+
8408
+ # 4a. Uninstall reverts a keel-set core.hooksPath.
8409
+ if run_keel(declared, "--uninstall").returncode != 0:
8410
+ report("fast-pre-push-hooks: uninstall failed.")
8411
+ return 1
8412
+ if hooks_path(declared) is not None:
8413
+ report("fast-pre-push-hooks: uninstall did not unset a keel-set core.hooksPath.")
8414
+ return 1
8415
+
8416
+ # 4b. Uninstall leaves a non-.githooks core.hooksPath untouched.
8417
+ custom = init_repo(root, "custom")
8418
+ git(custom, "config", "--local", "core.hooksPath", ".customhooks")
8419
+ if run_keel(custom, "--uninstall").returncode != 0:
8420
+ report("fast-pre-push-hooks: uninstall failed on a custom hooksPath repo.")
8421
+ return 1
8422
+ if hooks_path(custom) != ".customhooks":
8423
+ report("fast-pre-push-hooks: uninstall clobbered a non-keel core.hooksPath.")
8424
+ return 1
8425
+
8426
+ report("fast-pre-push-hooks scenario passed.")
8427
+ return 0
8428
+
8429
+
8430
+ def validate_fast_pre_push_doctor_scenario() -> int:
8431
+ def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
8432
+ return subprocess.run(
8433
+ ["git", "-C", str(repo), *args], capture_output=True, text=True
8434
+ )
8435
+
8436
+ def init_repo(root: Path, name: str) -> Path:
8437
+ repo = root / name
8438
+ repo.mkdir()
8439
+ git(repo, "init", "-q")
8440
+ git(repo, "config", "user.email", "t@example.com")
8441
+ git(repo, "config", "user.name", "keel-test")
8442
+ return repo
8443
+
8444
+ with tempfile.TemporaryDirectory(prefix="keel-prepush-doc-") as raw_tmp:
8445
+ root = Path(raw_tmp)
8446
+
8447
+ # Surface active: fast_check declared and --with-git-hooks applied.
8448
+ active = init_repo(root, "active")
8449
+ (active / "keel").mkdir()
8450
+ (active / "keel" / "config.yaml").write_text(
8451
+ "fast_check: echo doc-check\n", encoding="utf-8"
8452
+ )
8453
+ if run_keel(active, "--install", "--with-git-hooks").returncode != 0:
8454
+ report("fast-pre-push-doctor: install --with-git-hooks failed.")
8455
+ return 1
8456
+ before = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
8457
+ out = run_keel(active, "--doctor").stdout
8458
+ for needle in (
8459
+ "Fast pre-push surface:",
8460
+ "fast_check: ok",
8461
+ "echo doc-check",
8462
+ "pre-push hook: ok",
8463
+ "core.hooksPath: ok",
8464
+ ):
8465
+ if needle not in out:
8466
+ report(f"fast-pre-push-doctor: active-surface doctor output lacks: {needle}")
8467
+ report(out)
8468
+ return 1
8469
+ after = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
8470
+ if before != after:
8471
+ report("fast-pre-push-doctor: doctor mutated core.hooksPath.")
8472
+ return 1
8473
+
8474
+ # Surface absent: plain install, no fast_check, no hook.
8475
+ absent = init_repo(root, "absent")
8476
+ if run_keel(absent, "--install").returncode != 0:
8477
+ report("fast-pre-push-doctor: plain install failed.")
8478
+ return 1
8479
+ out = run_keel(absent, "--doctor").stdout
8480
+ for needle in ("fast_check: none", "pre-push hook: none", "core.hooksPath: unset"):
8481
+ if needle not in out:
8482
+ report(f"fast-pre-push-doctor: absent-surface doctor output lacks: {needle}")
8483
+ report(out)
8484
+ return 1
8485
+
8486
+ report("fast-pre-push-doctor scenario passed.")
8487
+ return 0
8488
+
8489
+
8490
+ def validate_verify_layer_tag_scenario() -> int:
8491
+ fixture = (
8492
+ "# Tasks\n\n"
8493
+ "- [ ] 1.1 Exercise the verification-layer tag\n"
8494
+ " - Covers:\n"
8495
+ " - E1: Public behavior passes.\n"
8496
+ " - Read:\n"
8497
+ " - README.md\n"
8498
+ " - Touch:\n"
8499
+ " - src/feature.js\n"
8500
+ " - Verify:\n"
8501
+ " - Strategy: evidence-first\n"
8502
+ " - M1 (fast): node fast.js\n"
8503
+ " - M2: node full.js\n"
8504
+ " - Autonomy boundary:\n"
8505
+ " - Default: hard-stop\n"
8506
+ " - Pre-authorized fallback: none\n"
8507
+ " - Stop Rules:\n"
8508
+ " - Stop on failure.\n"
8509
+ " - Evidence:\n"
8510
+ " - M1: pending\n"
8511
+ " - M2: pending\n"
8512
+ " - Stop if:\n"
8513
+ " - Requires files outside Touch.\n"
8514
+ )
8515
+ with tempfile.TemporaryDirectory(prefix="keel-verify-layer-") as raw_tmp:
8516
+ repo = Path(raw_tmp)
8517
+ write_text(repo / "openspec/changes/demo/tasks.md", fixture)
8518
+ started = run_keel(
8519
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
8520
+ )
8521
+ if started.returncode != 0:
8522
+ report("verify-layer-tag: task-start rejected the tagged fixture.")
8523
+ report((started.stderr or started.stdout).strip())
8524
+ return 1
8525
+ capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
8526
+ commands = capsule.get("verification", {}).get("commands", [])
8527
+ by_label = {c.get("label"): c for c in commands}
8528
+ if by_label.get("M1", {}).get("layer") != "fast":
8529
+ report("verify-layer-tag: the (fast)-tagged check did not compile with layer fast.")
8530
+ report(json.dumps(commands))
8531
+ return 1
8532
+ if "layer" in by_label.get("M2", {}):
8533
+ report(
8534
+ "verify-layer-tag: an untagged check emitted a layer field; full is "
8535
+ "the implicit default and must stay off the capsule."
8536
+ )
8537
+ report(json.dumps(commands))
8538
+ return 1
8539
+ if (
8540
+ by_label.get("M1", {}).get("check") != "node fast.js"
8541
+ or by_label.get("M2", {}).get("check") != "node full.js"
8542
+ ):
8543
+ report("verify-layer-tag: the layer tag altered the check text.")
8544
+ report(json.dumps(commands))
8545
+ return 1
8546
+
8547
+ report("verify-layer-tag scenario passed.")
8548
+ return 0
8549
+
8550
+
7690
8551
  def validate_native_goal_gate_order_scenario() -> int:
7691
8552
  with tempfile.TemporaryDirectory(prefix="keel-goal-order-") as raw_tmp:
7692
8553
  root = Path(raw_tmp)
@@ -8819,12 +9680,16 @@ def validate_thin_native_install_scenario() -> int:
8819
9680
  report(uncertain_text.strip())
8820
9681
  return 1
8821
9682
 
8822
- # --- Case E: doctor reports the missing native plugin with remediation ---
9683
+ # --- Case E: doctor reports the native plugin runtime with remediation ---
9684
+ # These repos consume Keel, so the runtime line and its install
9685
+ # remediation must appear, while the plugin *source* check must not:
9686
+ # plugins/keel/ exists only in Keel's own repository and `keel --init`
9687
+ # never creates it, so reporting it here is a permanent unactionable
9688
+ # `missing`. See the dev-only-plugin-source-scoping scenario.
8823
9689
  doctor = run_keel(repo, "--doctor")
8824
9690
  doctor_text = (doctor.stdout or "") + (doctor.stderr or "")
8825
9691
  if (
8826
- "native plugin source" not in doctor_text
8827
- or "native plugin runtime" not in doctor_text
9692
+ "native plugin runtime" not in doctor_text
8828
9693
  or "keel@<marketplace>" not in doctor_text
8829
9694
  ):
8830
9695
  report(
@@ -8833,14 +9698,21 @@ def validate_thin_native_install_scenario() -> int:
8833
9698
  )
8834
9699
  report(doctor_text.strip())
8835
9700
  return 1
9701
+ if "native plugin source" in doctor_text:
9702
+ report(
9703
+ "thin-native-install doctor reported the development-only "
9704
+ "plugin source check in a consuming project."
9705
+ )
9706
+ report(doctor_text.strip())
9707
+ return 1
8836
9708
  missing_repo = tmp / "no-plugin"
8837
9709
  missing_repo.mkdir()
8838
9710
  missing_doctor = run_keel(missing_repo, "--doctor")
8839
9711
  missing_text = (missing_doctor.stdout or "") + (missing_doctor.stderr or "")
8840
- if "plugin source absent" not in missing_text:
9712
+ if "plugin source" in missing_text:
8841
9713
  report(
8842
- "thin-native-install doctor does not diagnose an absent native "
8843
- "plugin source."
9714
+ "thin-native-install doctor diagnosed a plugin source outside "
9715
+ "Keel's own repository."
8844
9716
  )
8845
9717
  report(missing_text.strip())
8846
9718
  return 1
@@ -10104,6 +10976,29 @@ SCENARIOS: tuple = (
10104
10976
  ("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
10105
10977
  ("update-pack-install", validate_update_pack_install_scenario),
10106
10978
  ("update-default-registry", validate_update_default_registry_scenario),
10979
+ ("verification-layering-docs", validate_verification_layering_docs_scenario),
10980
+ ("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
10981
+ ("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
10982
+ ("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
10983
+ ("verify-layer-tag", validate_verify_layer_tag_scenario),
10984
+ (
10985
+ "non-concrete-verify-diagnostic",
10986
+ validate_non_concrete_verify_diagnostic_scenario,
10987
+ ),
10988
+ ("inline-code-is-concrete", validate_inline_code_is_concrete_scenario),
10989
+ ("covers-separator-collision", validate_covers_separator_collision_scenario),
10990
+ (
10991
+ "unresolved-authority-names-field",
10992
+ validate_unresolved_authority_names_field_scenario,
10993
+ ),
10994
+ (
10995
+ "dev-only-plugin-source-scoping",
10996
+ validate_dev_only_plugin_source_scoping_scenario,
10997
+ ),
10998
+ (
10999
+ "source-repo-bootstrap-skip",
11000
+ validate_source_repo_bootstrap_skip_scenario,
11001
+ ),
10107
11002
  ("task-contract-core", validate_task_contract_core_scenario),
10108
11003
  ("task-capsule", validate_task_capsule_scenario),
10109
11004
  ("task-verification-strategies", validate_task_verification_strategies_scenario),