@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.
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +13 -2
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/install_to_repo.py +28 -1
- package/scripts/validate_plugin.py +621 -22
- package/src/core/capabilities.js +25 -5
- package/src/core/gates.js +50 -18
- package/src/core/task-contract.js +127 -14
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
|
-
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.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.
|
|
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
|
-
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.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
|
|
4891
|
-
#
|
|
4892
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !=
|
|
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
|
-
|
|
5006
|
-
for
|
|
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
|
|
5012
|
-
"must
|
|
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
|
|
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
|
|
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
|
|
9712
|
+
if "plugin source" in missing_text:
|
|
9132
9713
|
report(
|
|
9133
|
-
"thin-native-install doctor
|
|
9134
|
-
"
|
|
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),
|
package/src/core/capabilities.js
CHANGED
|
@@ -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*
|
|
114
|
+
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
|
|
115
115
|
if (match) {
|
|
116
|
-
return {
|
|
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
|
-
//
|
|
128
|
-
// task's
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
|
140
|
-
+
|
|
141
|
-
+ "
|
|
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
|
-
|
|
192
|
+
const anchorLine =
|
|
181
193
|
`${anchorPlan.indent}- Contract: keel-task-capsule/v1 `
|
|
182
194
|
+ `sha256:${compiled.fingerprint.value}${anchorPlan.cr}`;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
anchorPlan.lines.
|
|
186
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
317
|
-
|
|
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:
|
|
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}
|
|
664
|
-
+ "fallback
|
|
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 = {
|