@christang/keel 5.2.2 → 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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.2.2 -->
1
+ <!-- keel:start version=5.2.3 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/bin/keel.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  runGate,
16
16
  } = require("../src/core/gates");
17
17
  const {
18
+ isKeelSourceRepo,
18
19
  probeCapabilities,
19
20
  renderCapabilities,
20
21
  } = require("../src/core/capabilities");
@@ -1202,7 +1203,12 @@ function printTargetSurface(repo, target) {
1202
1203
  sourceDetail = `plugin source unreadable at ${manifestRelative}`;
1203
1204
  }
1204
1205
  }
1205
- printDoctorLine("native plugin source", sourceStatus, sourceDetail);
1206
+ // Development-only check: plugins/keel/ exists only in Keel's own source
1207
+ // repository, so in a consuming project it is permanently `missing` and
1208
+ // there is nothing the author can do about it.
1209
+ if (isKeelSourceRepo(repo)) {
1210
+ printDoctorLine("native plugin source", sourceStatus, sourceDetail);
1211
+ }
1206
1212
  printDoctorLine(
1207
1213
  "native plugin runtime",
1208
1214
  "manual",
@@ -1229,7 +1235,12 @@ function printTargetSurface(repo, target) {
1229
1235
  printDoctorLine(
1230
1236
  "Keel behavioral skills",
1231
1237
  "plugin",
1232
- "keel-* skills are delivered by the installed Keel plugin (see native plugin status above); install the plugin if it is missing"
1238
+ isKeelSourceRepo(repo)
1239
+ ? "keel-* skills are delivered by the installed Keel plugin (see native "
1240
+ + "plugin status above); install the plugin if it is missing"
1241
+ : "keel-* skills are delivered by the installed Keel plugin; verify it "
1242
+ + "with the runtime's own plugin listing, since Keel cannot observe "
1243
+ + "installation from this repository"
1233
1244
  );
1234
1245
 
1235
1246
  const commands = commandSurfaceForTarget(target, repo);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.2.2",
5
+ "version": "5.2.3",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.2.2",
3
+ "version": "5.2.3",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.2.2",
3
+ "version": "5.2.3",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -432,11 +432,38 @@ def unmanaged_keel_content_warning(repo: Path, relative_path: str) -> bool:
432
432
  return False
433
433
 
434
434
 
435
+ KEEL_PACKAGE_NAME = "@christang/keel"
436
+
437
+
438
+ def is_keel_source_repo(repo: Path) -> bool:
439
+ """Whether `repo` is Keel's own source repository.
440
+
441
+ Mirrors `isKeelSourceRepo` in src/core/capabilities.js. Both signals are
442
+ required so a project that merely vendors a plugins/keel/ directory is not
443
+ misclassified as Keel's own source.
444
+ """
445
+ try:
446
+ manifest = json.loads((repo / "package.json").read_text(encoding="utf-8"))
447
+ except (OSError, ValueError):
448
+ return False
449
+ if manifest.get("name") != KEEL_PACKAGE_NAME:
450
+ return False
451
+ return (repo / "plugins" / "keel").is_dir()
452
+
453
+
435
454
  def collect_actions(repo: Path, target: str) -> list[InstallAction]:
436
455
  actions: list[InstallAction] = []
437
456
  targets = target_set(target)
438
457
 
439
- if not unmanaged_keel_content_warning(repo, "AGENTS.md"):
458
+ # Keel's own AGENTS.md carries the full protocol that the validation suite
459
+ # asserts on; the packaged bootstrap is the shorter consumer-facing text.
460
+ # Writing it here replaces the protocol and turns the repository red.
461
+ if is_keel_source_repo(repo):
462
+ print(
463
+ "skip AGENTS.md: Keel source repository, whose AGENTS.md carries "
464
+ "the full protocol; the consumer bootstrap is not written here"
465
+ )
466
+ elif not unmanaged_keel_content_warning(repo, "AGENTS.md"):
440
467
  actions.append(
441
468
  managed_file_action("AGENTS.md", PACKAGE_ROOT / BOOTSTRAP_ASSET)
442
469
  )
@@ -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.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
@@ -9110,12 +9680,16 @@ def validate_thin_native_install_scenario() -> int:
9110
9680
  report(uncertain_text.strip())
9111
9681
  return 1
9112
9682
 
9113
- # --- 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.
9114
9689
  doctor = run_keel(repo, "--doctor")
9115
9690
  doctor_text = (doctor.stdout or "") + (doctor.stderr or "")
9116
9691
  if (
9117
- "native plugin source" not in doctor_text
9118
- or "native plugin runtime" not in doctor_text
9692
+ "native plugin runtime" not in doctor_text
9119
9693
  or "keel@<marketplace>" not in doctor_text
9120
9694
  ):
9121
9695
  report(
@@ -9124,14 +9698,21 @@ def validate_thin_native_install_scenario() -> int:
9124
9698
  )
9125
9699
  report(doctor_text.strip())
9126
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
9127
9708
  missing_repo = tmp / "no-plugin"
9128
9709
  missing_repo.mkdir()
9129
9710
  missing_doctor = run_keel(missing_repo, "--doctor")
9130
9711
  missing_text = (missing_doctor.stdout or "") + (missing_doctor.stderr or "")
9131
- if "plugin source absent" not in missing_text:
9712
+ if "plugin source" in missing_text:
9132
9713
  report(
9133
- "thin-native-install doctor does not diagnose an absent native "
9134
- "plugin source."
9714
+ "thin-native-install doctor diagnosed a plugin source outside "
9715
+ "Keel's own repository."
9135
9716
  )
9136
9717
  report(missing_text.strip())
9137
9718
  return 1
@@ -10400,6 +10981,24 @@ SCENARIOS: tuple = (
10400
10981
  ("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
10401
10982
  ("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
10402
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
+ ),
10403
11002
  ("task-contract-core", validate_task_contract_core_scenario),
10404
11003
  ("task-capsule", validate_task_capsule_scenario),
10405
11004
  ("task-verification-strategies", validate_task_verification_strategies_scenario),
@@ -35,10 +35,33 @@ function codexHome() {
35
35
  return path.resolve(configured || path.join(os.homedir(), ".codex"));
36
36
  }
37
37
 
38
+ // Keel's own repository ships the plugin source under plugins/keel/. A project
39
+ // that consumes Keel never has it, and `keel --init` never creates it, so any
40
+ // check reading that path is a development-only check. Require both signals so
41
+ // a project that merely vendors a plugins/keel/ directory is not misread as
42
+ // Keel's own source.
43
+ function isKeelSourceRepo(repo) {
44
+ try {
45
+ const manifest = JSON.parse(
46
+ fs.readFileSync(path.join(repo, "package.json"), "utf8")
47
+ );
48
+ if (manifest.name !== "@christang/keel") return false;
49
+ } catch {
50
+ return false;
51
+ }
52
+ return fs.existsSync(path.join(repo, "plugins", "keel"));
53
+ }
54
+
55
+ const PLUGIN_RUNTIME_OBSERVATION =
56
+ "installed/enabled/trusted/active/behavior-verified plugin states need "
57
+ + "native runtime evidence and remain advisory or manual until probed";
58
+
38
59
  function pluginObservation(repo, target) {
39
60
  if (target === "opencode") {
40
61
  return "OpenCode has no v4 native plugin surface; manual CLI compatibility only";
41
62
  }
63
+ // The plugin source path is meaningful only in Keel's own repository.
64
+ if (!isKeelSourceRepo(repo)) return PLUGIN_RUNTIME_OBSERVATION;
42
65
  const manifestRelative = path.join(
43
66
  "plugins",
44
67
  "keel",
@@ -58,11 +81,7 @@ function pluginObservation(repo, target) {
58
81
  sourceState = `plugin source unreadable at ${manifestRelative}`;
59
82
  }
60
83
  }
61
- return (
62
- `${sourceState}; installed/enabled/trusted/active/behavior-verified plugin `
63
- + "states need native runtime evidence and remain advisory or manual "
64
- + "until probed"
65
- );
84
+ return `${sourceState}; ${PLUGIN_RUNTIME_OBSERVATION}`;
66
85
  }
67
86
 
68
87
  function targetObservation(repo, target) {
@@ -286,6 +305,7 @@ function renderCapabilities(result) {
286
305
 
287
306
  module.exports = {
288
307
  CAPABILITY_COMMANDS,
308
+ isKeelSourceRepo,
289
309
  probeCapabilities,
290
310
  renderCapabilities,
291
311
  };
package/src/core/gates.js CHANGED
@@ -111,24 +111,36 @@ function contractAnchorPlan(selection, task) {
111
111
  ? selection.tasks[index + 1].line
112
112
  : lines.length;
113
113
  for (let cursor = task.line; cursor < end; cursor += 1) {
114
- const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*pending(\r?)$/);
114
+ const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
115
115
  if (match) {
116
- return { lines, cursor, indent: match[1], cr: match[2] };
116
+ return {
117
+ lines,
118
+ cursor,
119
+ indent: match[1],
120
+ previous: match[2].trim(),
121
+ cr: match[3],
122
+ };
117
123
  }
118
124
  }
119
125
  return null;
120
126
  }
121
127
 
128
+ function anchoredFingerprint(previous) {
129
+ const match = previous.match(/sha-?256[\s:`]*([a-f0-9]{64})/i);
130
+ return match ? match[1].toLowerCase() : null;
131
+ }
132
+
122
133
  function taskStart(repo, options) {
123
134
  const selection = loadSelection(repo, options);
124
135
  const task = selection.selected[0];
125
136
  const compiled = compileTaskContract(repo, selection.change, task);
126
137
  const problems = [...compiled.diagnostics];
127
- // The explicit --record anchor write is refused loudly when the selected
128
- // task's Evidence has no literal pending Contract line: a silent skip would
129
- // hide a stale anchor and an overwrite would destroy the recorded start
130
- // evidence drift detection depends on. Refusal writes nothing, guard
131
- // manifest included.
138
+ // Recording the current fingerprint is idempotent: --record replaces the
139
+ // selected task's Contract anchor whatever it holds, so reauthorizing a task
140
+ // whose authority changed the path the guard's own drift messages direct
141
+ // authors to needs no manual edit. Refusal is kept only for a task with no
142
+ // anchor at all, which is a malformed capsule rather than a reauthorization,
143
+ // and it writes nothing, guard manifest included.
132
144
  let anchorPlan = null;
133
145
  if (options.record && problems.length === 0) {
134
146
  anchorPlan = contractAnchorPlan(selection, task);
@@ -136,9 +148,9 @@ function taskStart(repo, options) {
136
148
  problems.push(
137
149
  problem(
138
150
  "record-refused",
139
- "--record requires the selected task's Evidence to contain the "
140
- + 'literal line "- Contract: pending"; the anchor is already '
141
- + "recorded or missing, so nothing was written."
151
+ "--record needs a \"- Contract:\" Evidence line on the selected "
152
+ + "task to anchor, and this task has none, so nothing was "
153
+ + 'written. Add "- Contract: pending" to its Evidence.'
142
154
  )
143
155
  );
144
156
  }
@@ -177,19 +189,38 @@ function taskStart(repo, options) {
177
189
  }
178
190
  }
179
191
  if (result.status === "pass" && anchorPlan) {
180
- anchorPlan.lines[anchorPlan.cursor] =
192
+ const anchorLine =
181
193
  `${anchorPlan.indent}- Contract: keel-task-capsule/v1 `
182
194
  + `sha256:${compiled.fingerprint.value}${anchorPlan.cr}`;
183
- fs.writeFileSync(
184
- selection.tasksPath,
185
- anchorPlan.lines.join("\n"),
186
- "utf8"
187
- );
195
+ const unchanged = anchorPlan.lines[anchorPlan.cursor] === anchorLine;
196
+ if (!unchanged) {
197
+ anchorPlan.lines[anchorPlan.cursor] = anchorLine;
198
+ fs.writeFileSync(
199
+ selection.tasksPath,
200
+ anchorPlan.lines.join("\n"),
201
+ "utf8"
202
+ );
203
+ }
204
+ const wasPending = /^pending$/i.test(anchorPlan.previous);
188
205
  result.record = {
189
- status: "recorded",
206
+ status: unchanged ? "unchanged" : wasPending ? "recorded" : "rerecorded",
190
207
  path: `openspec/changes/${selection.change}/tasks.md`,
191
208
  line: anchorPlan.cursor + 1,
209
+ previous: anchorPlan.previous,
192
210
  };
211
+ // A re-record that lands a different fingerprint is a contract change, so
212
+ // any Evidence already produced under the previous one is stale. The gate
213
+ // cannot judge which Evidence survives; it names the change and leaves the
214
+ // call to the current agent's Review.
215
+ const replaced = anchoredFingerprint(anchorPlan.previous);
216
+ if (replaced && replaced !== compiled.fingerprint.value) {
217
+ result.warnings.push(
218
+ `Re-recorded over a different contract: was sha256:${replaced}, now `
219
+ + `sha256:${compiled.fingerprint.value}. Execution evidence produced `
220
+ + "under the previous contract is stale; clear or re-verify it "
221
+ + "before completing this task."
222
+ );
223
+ }
193
224
  }
194
225
  return result;
195
226
  }
@@ -658,7 +689,8 @@ function renderGate(result) {
658
689
  }
659
690
  if (result.record) {
660
691
  lines.push(
661
- `Recorded: ${result.record.path}:${result.record.line} (Contract anchor)`
692
+ `Contract anchor ${result.record.status}: ${result.record.path}:`
693
+ + result.record.line
662
694
  );
663
695
  }
664
696
  return `${lines.join("\n")}\n`;
@@ -10,13 +10,37 @@ const SUPPORTED_MODES = new Set([
10
10
  "plan-first",
11
11
  ]);
12
12
 
13
- function isConcrete(value) {
14
- const normalized = String(value || "")
13
+ const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
14
+
15
+ function normalizeFieldText(value) {
16
+ return String(value || "")
15
17
  .replace(/<!--[\s\S]*?-->/g, "")
16
18
  .replace(/^\s*-\s*/gm, "")
17
19
  .trim();
20
+ }
21
+
22
+ // Inline code spans hold documented patterns — a filename shape, or prose that
23
+ // has to name the token forms themselves. Strip them before looking for an
24
+ // unfilled slot, but only after the emptiness test, so a field whose whole
25
+ // value is one code span is not mistaken for an empty field.
26
+ function withoutInlineCode(text) {
27
+ return text.replace(/`[^`]*`/g, " ");
28
+ }
29
+
30
+ function isConcrete(value) {
31
+ const normalized = normalizeFieldText(value);
18
32
  if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return false;
19
- return !/(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i.test(normalized);
33
+ return !UNFILLED_TOKEN.test(withoutInlineCode(normalized));
34
+ }
35
+
36
+ // The unfilled token that made a field non-concrete, or null when the field is
37
+ // empty, `none`, or `pending`. Used to explain a non-concrete field instead of
38
+ // letting the caller infer a different schema from it.
39
+ function unfilledToken(value) {
40
+ const normalized = normalizeFieldText(value);
41
+ if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return null;
42
+ const match = withoutInlineCode(normalized).match(UNFILLED_TOKEN);
43
+ return match ? match[0] : null;
20
44
  }
21
45
 
22
46
  function parseTasks(content) {
@@ -245,7 +269,27 @@ function taskStartContractProblems(task) {
245
269
  }
246
270
 
247
271
  function requiredFieldProblems(task) {
248
- const compact = isConcrete(field(task, "Verify"));
272
+ const verify = field(task, "Verify");
273
+ const compact = isConcrete(verify);
274
+ // A task that declared Verify but left an unfilled token in it is a compact
275
+ // v4 task with one bad token, not an expanded v3 task. Say which token, and
276
+ // do not report the v3 fields it never declared.
277
+ if (!compact) {
278
+ const token = unfilledToken(verify);
279
+ if (token) {
280
+ return [
281
+ {
282
+ code: "non-concrete-verify",
283
+ message:
284
+ `Verify contains the unfilled token \`${token}\`; compact v4 `
285
+ + "detection requires a concrete Verify. Replace or remove that "
286
+ + "token — the expanded v3 fields are not required. Angle "
287
+ + "brackets, TODO, TBD, and the word placeholder all read as "
288
+ + "unfilled, including inside prose.",
289
+ },
290
+ ];
291
+ }
292
+ }
249
293
  const required = compact
250
294
  ? ["Covers", "Verify", "Evidence"]
251
295
  : [
@@ -313,12 +357,8 @@ function scenarioOutcomes(content) {
313
357
  ].map((match) => normalizeText(match[1]));
314
358
  }
315
359
 
316
- function specAuthority(repo, change, reference) {
317
- const parts = reference.split("/").map((part) => part.trim());
318
- if (parts.length < 2 || parts.length > 3) return null;
319
- const [capability, requirementName, scenarioName] = parts;
320
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
321
- const candidates = [
360
+ function specCandidatePaths(repo, change, capability) {
361
+ return [
322
362
  path.join(
323
363
  repo,
324
364
  "openspec",
@@ -330,6 +370,68 @@ function specAuthority(repo, change, reference) {
330
370
  ),
331
371
  path.join(repo, "openspec", "specs", capability, "spec.md"),
332
372
  ];
373
+ }
374
+
375
+ // Requirement and scenario names that contain the hierarchy separator can never
376
+ // be referenced, whatever the author writes, so name them instead of leaving a
377
+ // correct-looking reference unexplained.
378
+ function separatorCollisions(repo, change, capability) {
379
+ const collisions = [];
380
+ for (const specPath of specCandidatePaths(repo, change, capability)) {
381
+ if (!fs.existsSync(specPath)) continue;
382
+ const content = fs.readFileSync(specPath, "utf8");
383
+ for (const pattern of [
384
+ /^### Requirement:\s*(.+?)\s*$/gm,
385
+ /^#### Scenario:\s*(.+?)\s*$/gm,
386
+ ]) {
387
+ for (const match of content.matchAll(pattern)) {
388
+ if (match[1].includes("/") && !collisions.includes(match[1])) {
389
+ collisions.push(match[1]);
390
+ }
391
+ }
392
+ }
393
+ }
394
+ return collisions;
395
+ }
396
+
397
+ function collisionHint(repo, change, capability) {
398
+ const collisions = separatorCollisions(repo, change, capability);
399
+ if (collisions.length === 0) return "";
400
+ const named = collisions.map((name) => `"${name}"`).join(", ");
401
+ return (
402
+ ` Capability ${capability} declares a name containing the / separator, `
403
+ + `which cannot be referenced: ${named}. Rename it in the spec, or `
404
+ + "reference its parent requirement instead."
405
+ );
406
+ }
407
+
408
+ function specAuthority(repo, change, reference) {
409
+ const parts = reference.split("/").map((part) => part.trim());
410
+ const [capability, requirementName, scenarioName] = parts;
411
+ const namesCapability =
412
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability || "")
413
+ && specCandidatePaths(repo, change, capability).some((specPath) =>
414
+ fs.existsSync(specPath)
415
+ );
416
+ if (parts.length < 2 || parts.length > 3) {
417
+ // Only a reference that names a real capability is a failed spec
418
+ // reference; anything else is free text and stays a legacy reference.
419
+ if (parts.length > 3 && namesCapability) {
420
+ return {
421
+ diagnostic: {
422
+ code: "unresolved-covers",
423
+ message:
424
+ `Covers reference has ${parts.length} segments; the hierarchy is `
425
+ + "capability / requirement, or capability / requirement / "
426
+ + `scenario: ${reference}.`
427
+ + collisionHint(repo, change, capability),
428
+ },
429
+ };
430
+ }
431
+ return null;
432
+ }
433
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
434
+ const candidates = specCandidatePaths(repo, change, capability);
333
435
  for (const specPath of candidates) {
334
436
  if (!fs.existsSync(specPath)) continue;
335
437
  const content = fs.readFileSync(specPath, "utf8");
@@ -361,7 +463,10 @@ function specAuthority(repo, change, reference) {
361
463
  code: scenarios.length > 1 ? "ambiguous-covers" : "unresolved-covers",
362
464
  message:
363
465
  `${scenarios.length > 1 ? "Duplicated" : "Missing"} Covers `
364
- + `scenario: ${reference}.`,
466
+ + `scenario: ${reference}.`
467
+ + (scenarios.length > 1
468
+ ? ""
469
+ : collisionHint(repo, change, capability)),
365
470
  },
366
471
  };
367
472
  }
@@ -383,7 +488,9 @@ function specAuthority(repo, change, reference) {
383
488
  return {
384
489
  diagnostic: {
385
490
  code: "unresolved-covers",
386
- message: `Covers reference could not be resolved: ${reference}.`,
491
+ message:
492
+ `Covers reference could not be resolved: ${reference}.`
493
+ + collisionHint(repo, change, capability),
387
494
  },
388
495
  };
389
496
  }
@@ -657,11 +764,17 @@ function compileTaskContract(repo, change, task) {
657
764
  questionIds.length > 0
658
765
  && !isConcrete(fallback.replace(/^Pre-authorized fallback:\s*/i, ""))
659
766
  ) {
767
+ // Name the field and prefix this check actually reads. The previous
768
+ // wording said "documented design authority", which sent authors to
769
+ // design.md — where the answer usually already is.
660
770
  resolved.diagnostics.push(...questionIds.map((questionId) => ({
661
771
  code: "unresolved-authority",
662
772
  message:
663
- `${questionId} requires documented design authority and an authorized `
664
- + "fallback before implementation.",
773
+ `${questionId} is referenced in Covers but task ${task.id} declares no `
774
+ + "authorized fallback. Add an \"Autonomy boundary:\" field whose entry "
775
+ + "line begins \"Pre-authorized fallback:\" and states the reversible "
776
+ + "bound plus the evidence it requires. This check reads only that line "
777
+ + "on the task; prose in design.md does not satisfy it.",
665
778
  })));
666
779
  }
667
780
  const capsule = {