@christang/keel 5.3.3 → 5.3.5
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 +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +16 -4
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +16 -9
- 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/bump_version.js +46 -8
- package/scripts/validate_plugin.py +753 -123
- package/src/core/gates.js +110 -38
- package/src/core/task-contract.js +62 -16
|
@@ -37,10 +37,13 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.3.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.5"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.5"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
|
+
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
45
|
+
# signals is_keel_source_repo reads.
|
|
46
|
+
KEEL_PACKAGE_NAME = "@christang/keel"
|
|
44
47
|
OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
|
|
45
48
|
OPENSPEC_SCHEMA_ROOT = Path("openspec/schemas") / OPENSPEC_SCHEMA_NAME
|
|
46
49
|
OPENSPEC_SURFACE_OVERLAY_START = (
|
|
@@ -50,7 +53,6 @@ OPENSPEC_SURFACE_OVERLAY_END = "<!-- keel:openspec-surface-overlay:end -->"
|
|
|
50
53
|
|
|
51
54
|
SKILL_TARGETS = {"claude", "codex", "opencode"}
|
|
52
55
|
HOOK_TARGETS = {"claude"}
|
|
53
|
-
KEEL_HOOK_NAME = "keel-gate"
|
|
54
56
|
AGENT_TARGETS: set[str] = set()
|
|
55
57
|
ADAPTER_TARGETS = {"claude", "codex", "opencode"}
|
|
56
58
|
AGENT_PROTOCOL_TARGETS = {"codex", "opencode"}
|
|
@@ -415,6 +417,28 @@ def validate_npm_package(errors: list[str]) -> None:
|
|
|
415
417
|
errors.append(f"bin/keel.js missing required CLI support: {required}")
|
|
416
418
|
|
|
417
419
|
|
|
420
|
+
# Path expressions rooted at a tree the retirement check above requires to be
|
|
421
|
+
# absent. `src/core` and `src/skills` are live, so only the retired `src`
|
|
422
|
+
# children are listed.
|
|
423
|
+
# Keel subcommands that write. A scenario may point read-only ones at the
|
|
424
|
+
# repository root; these need a fixture.
|
|
425
|
+
MUTATING_KEEL_COMMANDS = (
|
|
426
|
+
"--install",
|
|
427
|
+
"--init",
|
|
428
|
+
"--uninstall",
|
|
429
|
+
"--clear",
|
|
430
|
+
"--update",
|
|
431
|
+
"--with-git-hooks",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
RETIRED_PATH_EXPRESSIONS = (
|
|
435
|
+
r'ROOT\s*/\s*"dist"',
|
|
436
|
+
r'ROOT\s*/\s*"src"\s*/\s*"assets"',
|
|
437
|
+
r'ROOT\s*/\s*"src"\s*/\s*"hooks"',
|
|
438
|
+
r'ROOT\s*/\s*"src"\s*/\s*"adapters"',
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
418
442
|
def validate_paths(errors: list[str]) -> None:
|
|
419
443
|
for directory in REQUIRED_DIRECTORIES:
|
|
420
444
|
if not (ROOT / directory).is_dir():
|
|
@@ -448,6 +472,63 @@ def validate_paths(errors: list[str]) -> None:
|
|
|
448
472
|
f"retired custom distribution path must be removed: {retired}"
|
|
449
473
|
)
|
|
450
474
|
|
|
475
|
+
validator_source = (ROOT / "scripts" / "validate_plugin.py").read_text(
|
|
476
|
+
encoding="utf-8"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# A scenario that writes to the repository it validates can satisfy the very
|
|
480
|
+
# condition another check asserts, and a check whose input its own run
|
|
481
|
+
# produces cannot fail. Reads against ROOT are fine and common; writes are
|
|
482
|
+
# not. Keyed on the mutating subcommand rather than on ROOT itself, so
|
|
483
|
+
# `--version`, `--doctor`, and the gates stay legal.
|
|
484
|
+
for line_number, line in enumerate(validator_source.splitlines(), start=1):
|
|
485
|
+
invocation = re.search(r"run_(?:keel|install)\(\s*ROOT\s*,([^)]*)", line)
|
|
486
|
+
if not invocation:
|
|
487
|
+
continue
|
|
488
|
+
if any(
|
|
489
|
+
re.search(rf'"{command}"', invocation.group(1))
|
|
490
|
+
for command in MUTATING_KEEL_COMMANDS
|
|
491
|
+
):
|
|
492
|
+
errors.append(
|
|
493
|
+
"a scenario must not run a mutating Keel command against the "
|
|
494
|
+
"repository it validates; build a fixture instead: "
|
|
495
|
+
f"scripts/validate_plugin.py:{line_number}: {line.strip()}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Every Keel marker that carries a version is a shipped claim about which
|
|
499
|
+
# version this is. Derive the set from the markers that exist rather than a
|
|
500
|
+
# fixed list, because a fixed list is the next thing to fall behind — which
|
|
501
|
+
# is exactly how the `.codex/` overlays sat four versions back unnoticed.
|
|
502
|
+
for marker_file in sorted(ROOT.rglob("*")):
|
|
503
|
+
if not marker_file.is_file() or not marker_file.suffix in (".md", ".json"):
|
|
504
|
+
continue
|
|
505
|
+
relative = marker_file.relative_to(ROOT).as_posix()
|
|
506
|
+
if relative.startswith(("node_modules/", "openspec/changes/archive/", "keel/archive/")):
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
text = marker_file.read_text(encoding="utf-8")
|
|
510
|
+
except (UnicodeDecodeError, OSError):
|
|
511
|
+
continue
|
|
512
|
+
for found in re.findall(r"keel:[a-z-]+(?::end)?\s+version=([0-9][^\s>]*)", text):
|
|
513
|
+
if found != PACKAGE_VERSION:
|
|
514
|
+
errors.append(
|
|
515
|
+
"shipped version marker disagrees with the package version "
|
|
516
|
+
f"{PACKAGE_VERSION}: {relative} says {found}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Asserting the trees are gone is not enough: a check that still resolves a
|
|
520
|
+
# path into one of them can only ever find nothing, and rglob over a missing
|
|
521
|
+
# directory yields no error, so the check reports success forever. Naming a
|
|
522
|
+
# retired tree in a string literal is fine — that is how the checks above
|
|
523
|
+
# state what must not exist; building a Path into one is not.
|
|
524
|
+
for line_number, line in enumerate(validator_source.splitlines(), start=1):
|
|
525
|
+
if any(re.search(pattern, line) for pattern in RETIRED_PATH_EXPRESSIONS):
|
|
526
|
+
errors.append(
|
|
527
|
+
"validator resolves a path under a retired distribution tree, "
|
|
528
|
+
"so the check it feeds can only iterate nothing: "
|
|
529
|
+
f"scripts/validate_plugin.py:{line_number}: {line.strip()}"
|
|
530
|
+
)
|
|
531
|
+
|
|
451
532
|
|
|
452
533
|
def extract_managed_block(content: str) -> str | None:
|
|
453
534
|
start_match = MANAGED_START_RE.search(content)
|
|
@@ -571,10 +652,20 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
571
652
|
f"{template['name']} includes forbidden content: {forbidden}"
|
|
572
653
|
)
|
|
573
654
|
|
|
655
|
+
# What actually ships is whatever package.json declares, so derive the roots
|
|
656
|
+
# from there rather than naming a tree that can retire out from under the
|
|
657
|
+
# check the way `src/assets` and `dist` did.
|
|
658
|
+
packaged_roots = [
|
|
659
|
+
ROOT / entry
|
|
660
|
+
for entry in json.loads(
|
|
661
|
+
(ROOT / "package.json").read_text(encoding="utf-8")
|
|
662
|
+
).get("files", [])
|
|
663
|
+
if (ROOT / entry).is_dir()
|
|
664
|
+
]
|
|
665
|
+
|
|
574
666
|
active_task_placeholders = [
|
|
575
667
|
path.relative_to(ROOT).as_posix()
|
|
576
|
-
for base in
|
|
577
|
-
if base.exists()
|
|
668
|
+
for base in packaged_roots
|
|
578
669
|
for path in base.rglob("keel/TASK.md")
|
|
579
670
|
]
|
|
580
671
|
if active_task_placeholders:
|
|
@@ -585,18 +676,9 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
585
676
|
|
|
586
677
|
backlog_assets = [
|
|
587
678
|
path.relative_to(ROOT).as_posix()
|
|
588
|
-
for base in
|
|
589
|
-
if base.exists()
|
|
679
|
+
for base in packaged_roots
|
|
590
680
|
for path in base.rglob("keel/backlog/*")
|
|
591
681
|
]
|
|
592
|
-
backlog_assets.extend(
|
|
593
|
-
path.relative_to(ROOT).as_posix()
|
|
594
|
-
for path in (
|
|
595
|
-
ROOT / "src" / "assets" / "shared" / "backlog",
|
|
596
|
-
ROOT / "dist" / "shared" / "assets" / "backlog",
|
|
597
|
-
)
|
|
598
|
-
if path.exists()
|
|
599
|
-
)
|
|
600
682
|
if backlog_assets:
|
|
601
683
|
errors.append(
|
|
602
684
|
"package must not include keel backlog assets: "
|
|
@@ -606,7 +688,6 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
606
688
|
|
|
607
689
|
def validate_openspec_schema(errors: list[str]) -> None:
|
|
608
690
|
source_root = ROOT / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
609
|
-
dist_root = source_root
|
|
610
691
|
required_files = [
|
|
611
692
|
"schema.yaml",
|
|
612
693
|
"templates/proposal.md",
|
|
@@ -615,13 +696,12 @@ def validate_openspec_schema(errors: list[str]) -> None:
|
|
|
615
696
|
"templates/tasks.md",
|
|
616
697
|
]
|
|
617
698
|
|
|
618
|
-
for
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
)
|
|
699
|
+
for relative in required_files:
|
|
700
|
+
if not (source_root / relative).is_file():
|
|
701
|
+
errors.append(
|
|
702
|
+
"OpenSpec source schema missing file: "
|
|
703
|
+
f"{source_root.relative_to(ROOT).as_posix()}/{relative}"
|
|
704
|
+
)
|
|
625
705
|
|
|
626
706
|
schema_path = source_root / "schema.yaml"
|
|
627
707
|
tasks_template_path = source_root / "templates" / "tasks.md"
|
|
@@ -717,33 +797,11 @@ def validate_openspec_schema(errors: list[str]) -> None:
|
|
|
717
797
|
f"{forbidden}"
|
|
718
798
|
)
|
|
719
799
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
}
|
|
726
|
-
dist_files = {
|
|
727
|
-
path.relative_to(dist_root).as_posix(): path.read_text(encoding="utf-8")
|
|
728
|
-
for path in sorted(dist_root.rglob("*"))
|
|
729
|
-
if path.is_file()
|
|
730
|
-
}
|
|
731
|
-
if source_files != dist_files:
|
|
732
|
-
missing = sorted(set(source_files) - set(dist_files))
|
|
733
|
-
unexpected = sorted(set(dist_files) - set(source_files))
|
|
734
|
-
changed = sorted(
|
|
735
|
-
path
|
|
736
|
-
for path in set(source_files) & set(dist_files)
|
|
737
|
-
if source_files[path] != dist_files[path]
|
|
738
|
-
)
|
|
739
|
-
errors.append(
|
|
740
|
-
"OpenSpec dist schema differs from source"
|
|
741
|
-
+ (
|
|
742
|
-
f"; missing={missing}, unexpected={unexpected}, changed={changed}"
|
|
743
|
-
if missing or unexpected or changed
|
|
744
|
-
else ""
|
|
745
|
-
)
|
|
746
|
-
)
|
|
800
|
+
# A source-versus-dist comparison stood here, but `dist_root` was assigned
|
|
801
|
+
# `source_root`, so it diffed a directory against itself and could not fail.
|
|
802
|
+
# The pair that really needs comparing — this packaged copy against the
|
|
803
|
+
# repo-local one OpenSpec resolves — is asserted by
|
|
804
|
+
# `invalidation-authoring-surface`.
|
|
747
805
|
|
|
748
806
|
|
|
749
807
|
def validate_skill_docs(errors: list[str]) -> None:
|
|
@@ -946,18 +1004,17 @@ def snapshot_files(root: Path) -> dict[str, str]:
|
|
|
946
1004
|
return snapshot
|
|
947
1005
|
|
|
948
1006
|
|
|
949
|
-
def packaged_openspec_schema_install_paths() -> list[str]:
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
/ "assets"
|
|
955
|
-
/ "openspec"
|
|
956
|
-
/ "schemas"
|
|
957
|
-
/ OPENSPEC_SCHEMA_NAME
|
|
1007
|
+
def packaged_openspec_schema_install_paths(root: Path | None = None) -> list[str]:
|
|
1008
|
+
# The root the installer itself reads (install_to_repo.openspec_schema_actions),
|
|
1009
|
+
# which raises on the same condition. A validator that answered `[]` here left
|
|
1010
|
+
# six install/uninstall/clear assertions iterating nothing and reporting pass.
|
|
1011
|
+
schema_root = root if root is not None else (
|
|
1012
|
+
ROOT / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
958
1013
|
)
|
|
959
1014
|
if not schema_root.is_dir():
|
|
960
|
-
|
|
1015
|
+
raise FileNotFoundError(
|
|
1016
|
+
f"packaged OpenSpec schema root is missing: {schema_root}"
|
|
1017
|
+
)
|
|
961
1018
|
|
|
962
1019
|
return [
|
|
963
1020
|
(OPENSPEC_SCHEMA_ROOT / path.relative_to(schema_root)).as_posix()
|
|
@@ -2462,22 +2519,6 @@ def validate_update_default_registry_scenario() -> int:
|
|
|
2462
2519
|
return 0
|
|
2463
2520
|
|
|
2464
2521
|
|
|
2465
|
-
def run_keel_hook(repo: Path, event: dict) -> subprocess.CompletedProcess[str]:
|
|
2466
|
-
env = dict(os.environ)
|
|
2467
|
-
env["KEEL_CLI"] = str(ROOT / "bin/keel.js")
|
|
2468
|
-
return subprocess.run(
|
|
2469
|
-
["node", str(ROOT / "dist" / "claude" / "hooks" / KEEL_HOOK_NAME / "keel-gate.js")],
|
|
2470
|
-
cwd=repo,
|
|
2471
|
-
env=env,
|
|
2472
|
-
input=json.dumps(event),
|
|
2473
|
-
text=True,
|
|
2474
|
-
encoding="utf-8",
|
|
2475
|
-
errors="replace",
|
|
2476
|
-
capture_output=True,
|
|
2477
|
-
check=False,
|
|
2478
|
-
)
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
2522
|
def gate_task(
|
|
2482
2523
|
*,
|
|
2483
2524
|
checked: bool,
|
|
@@ -3642,13 +3683,561 @@ SCHEMA_COPY_PAIRS = (
|
|
|
3642
3683
|
)
|
|
3643
3684
|
|
|
3644
3685
|
|
|
3686
|
+
def validate_anchor_reverification_bound_scenario() -> int:
|
|
3687
|
+
label = "anchor-reverification-bound"
|
|
3688
|
+
|
|
3689
|
+
# The fingerprint is described as recompiled and compared at resume,
|
|
3690
|
+
# projection, and completion, with no stated bound. It holds while the
|
|
3691
|
+
# change is live: the capsule records each authority's source as a path
|
|
3692
|
+
# under the change directory, and archiving renames that directory. An
|
|
3693
|
+
# unstated boundary reads as no boundary, so demonstrate where it is.
|
|
3694
|
+
with tempfile.TemporaryDirectory(prefix="keel-anchor-bound-") as raw_tmp:
|
|
3695
|
+
repo = Path(raw_tmp) / "repo"
|
|
3696
|
+
repo.mkdir()
|
|
3697
|
+
live = repo / "openspec/changes/demo/tasks.md"
|
|
3698
|
+
write_text(live, task_contract_fixture(evidence=("Contract: pending", "M1: pending")))
|
|
3699
|
+
|
|
3700
|
+
recorded = run_keel(
|
|
3701
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
3702
|
+
"--record", "--json",
|
|
3703
|
+
)
|
|
3704
|
+
payload = json.loads(recorded.stdout)
|
|
3705
|
+
if payload.get("status") != "pass":
|
|
3706
|
+
report(f"{label} could not record an anchor on a live change.")
|
|
3707
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3708
|
+
return 1
|
|
3709
|
+
anchor = payload["contract"]["fingerprint"]["value"]
|
|
3710
|
+
|
|
3711
|
+
# Live: recompiling reproduces the recorded value, which is the
|
|
3712
|
+
# guarantee the resident protocol states.
|
|
3713
|
+
again = json.loads(
|
|
3714
|
+
run_keel(
|
|
3715
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
3716
|
+
).stdout
|
|
3717
|
+
)
|
|
3718
|
+
if again["contract"]["fingerprint"]["value"] != anchor:
|
|
3719
|
+
report(f"{label} a live anchor did not recompile to its recorded value.")
|
|
3720
|
+
return 1
|
|
3721
|
+
|
|
3722
|
+
# Archived: the gate refuses to select the change at all, so the bound
|
|
3723
|
+
# is enforced rather than merely documented.
|
|
3724
|
+
archived = repo / "openspec/changes/archive/2026-07-28-demo/tasks.md"
|
|
3725
|
+
write_text(archived, live.read_text(encoding="utf-8"))
|
|
3726
|
+
refused = run_keel(
|
|
3727
|
+
repo, "gate", "task-start",
|
|
3728
|
+
"--change", "archive/2026-07-28-demo", "--task", "1.1",
|
|
3729
|
+
)
|
|
3730
|
+
if refused.returncode == 0 or "invalid change name" not in (
|
|
3731
|
+
refused.stderr + refused.stdout
|
|
3732
|
+
):
|
|
3733
|
+
report(
|
|
3734
|
+
f"{label} the gate accepted an archived change; the bound this "
|
|
3735
|
+
"documents is supposed to be enforced, not advisory."
|
|
3736
|
+
)
|
|
3737
|
+
report((refused.stderr or refused.stdout).strip())
|
|
3738
|
+
return 1
|
|
3739
|
+
|
|
3740
|
+
# And the reason the refusal is right: compiling the archived copy
|
|
3741
|
+
# directly yields a different fingerprint, because each authority's
|
|
3742
|
+
# `source` names the directory the task now lives in.
|
|
3743
|
+
probe = subprocess.run(
|
|
3744
|
+
[
|
|
3745
|
+
"node", "-e",
|
|
3746
|
+
"const {loadTaskContract}=require(process.argv[1]);"
|
|
3747
|
+
"const c=loadTaskContract(process.argv[2],'archive/2026-07-28-demo','1.1');"
|
|
3748
|
+
"process.stdout.write(c.contract.fingerprint.value);",
|
|
3749
|
+
str(ROOT / "src/core/task-contract.js"),
|
|
3750
|
+
str(repo),
|
|
3751
|
+
],
|
|
3752
|
+
text=True, encoding="utf-8", capture_output=True, check=False,
|
|
3753
|
+
)
|
|
3754
|
+
if probe.returncode != 0:
|
|
3755
|
+
report(f"{label} could not compile the archived copy directly.")
|
|
3756
|
+
report((probe.stderr or probe.stdout).strip())
|
|
3757
|
+
return 1
|
|
3758
|
+
if probe.stdout.strip() == anchor:
|
|
3759
|
+
report(
|
|
3760
|
+
f"{label} the archived copy reproduced the anchor, so the "
|
|
3761
|
+
"documented bound no longer describes reality — revisit the "
|
|
3762
|
+
"protocol wording rather than relaxing this check."
|
|
3763
|
+
)
|
|
3764
|
+
return 1
|
|
3765
|
+
|
|
3766
|
+
# And the resident protocol must say where the guarantee stops.
|
|
3767
|
+
resident = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
3768
|
+
if "while its change is live" not in resident:
|
|
3769
|
+
report(
|
|
3770
|
+
f"{label} the resident protocol describes recompilation without "
|
|
3771
|
+
"stating that it holds while the change is live."
|
|
3772
|
+
)
|
|
3773
|
+
return 1
|
|
3774
|
+
|
|
3775
|
+
report(f"{label} scenario passed.")
|
|
3776
|
+
return 0
|
|
3777
|
+
|
|
3778
|
+
|
|
3779
|
+
def validate_authoring_surface_owner_and_tags_scenario() -> int:
|
|
3780
|
+
label = "authoring-surface-owner-and-tags"
|
|
3781
|
+
|
|
3782
|
+
# Both rules this change adds widen what a gate accepts. An author only
|
|
3783
|
+
# benefits if the shipped surface says so, so the template, the artifact
|
|
3784
|
+
# instruction the CLI hands back, and the resident protocol each state them.
|
|
3785
|
+
template = (
|
|
3786
|
+
ROOT / "openspec/schemas/keel-spec-driven/templates/tasks.md"
|
|
3787
|
+
).read_text(encoding="utf-8")
|
|
3788
|
+
resident = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
3789
|
+
|
|
3790
|
+
# Read the instruction the way an author receives it — through the CLI in a
|
|
3791
|
+
# repository Keel installed — rather than from the schema file it is
|
|
3792
|
+
# composed from, so a change that never reaches the author is a failure.
|
|
3793
|
+
with tempfile.TemporaryDirectory(prefix="keel-authoring-surface-") as raw_tmp:
|
|
3794
|
+
repo = Path(raw_tmp) / "repo"
|
|
3795
|
+
repo.mkdir()
|
|
3796
|
+
install = run_keel(repo, "--install")
|
|
3797
|
+
if install.returncode != 0:
|
|
3798
|
+
report(f"{label} keel --install failed.")
|
|
3799
|
+
report((install.stderr or install.stdout).strip())
|
|
3800
|
+
return 1
|
|
3801
|
+
created = run_openspec(repo, "new", "change", "surface-probe")
|
|
3802
|
+
if created is None:
|
|
3803
|
+
report(f"{label} skipped: the openspec CLI is not on PATH.")
|
|
3804
|
+
return 3
|
|
3805
|
+
if created.returncode != 0:
|
|
3806
|
+
report(f"{label} could not scaffold a change to read the instruction.")
|
|
3807
|
+
report((created.stderr or created.stdout).strip())
|
|
3808
|
+
return 1
|
|
3809
|
+
instructions = run_openspec(
|
|
3810
|
+
repo, "instructions", "tasks", "--change", "surface-probe"
|
|
3811
|
+
)
|
|
3812
|
+
if instructions is None or instructions.returncode != 0:
|
|
3813
|
+
report(f"{label} could not read the tasks artifact instruction.")
|
|
3814
|
+
if instructions is not None:
|
|
3815
|
+
report((instructions.stderr or instructions.stdout).strip())
|
|
3816
|
+
return 1
|
|
3817
|
+
instruction = instructions.stdout
|
|
3818
|
+
|
|
3819
|
+
for surface, text in (("tasks template", template), ("tasks instruction", instruction)):
|
|
3820
|
+
for needle in (
|
|
3821
|
+
"regression",
|
|
3822
|
+
"at least one check untagged",
|
|
3823
|
+
):
|
|
3824
|
+
if needle not in text:
|
|
3825
|
+
report(f"{label} {surface} does not describe the tag: {needle}")
|
|
3826
|
+
return 1
|
|
3827
|
+
# D6 — the wording trap: red and green accompany the bare label.
|
|
3828
|
+
if "in addition to" not in text.lower():
|
|
3829
|
+
report(
|
|
3830
|
+
f"{label} {surface} still reads as though `.red`/`.green` "
|
|
3831
|
+
"replace the bare M<n> Evidence rather than accompanying it."
|
|
3832
|
+
)
|
|
3833
|
+
return 1
|
|
3834
|
+
if "repo-relative path that exists" not in text:
|
|
3835
|
+
report(
|
|
3836
|
+
f"{label} {surface} does not state the existing-path owner form."
|
|
3837
|
+
)
|
|
3838
|
+
return 1
|
|
3839
|
+
if "HANDOFF" not in text:
|
|
3840
|
+
report(f"{label} {surface} does not state that HANDOFF is refused.")
|
|
3841
|
+
return 1
|
|
3842
|
+
|
|
3843
|
+
for needle in (
|
|
3844
|
+
"regression-only-strategy",
|
|
3845
|
+
"in addition to the bare",
|
|
3846
|
+
"any repo-relative path that exists",
|
|
3847
|
+
):
|
|
3848
|
+
if needle not in resident:
|
|
3849
|
+
report(f"{label} resident protocol does not state: {needle}")
|
|
3850
|
+
return 1
|
|
3851
|
+
|
|
3852
|
+
for local, packaged in SCHEMA_COPY_PAIRS:
|
|
3853
|
+
if (ROOT / local).read_text(encoding="utf-8") != (
|
|
3854
|
+
ROOT / packaged
|
|
3855
|
+
).read_text(encoding="utf-8"):
|
|
3856
|
+
report(f"{label} schema copies diverge: {local} vs {packaged}")
|
|
3857
|
+
return 1
|
|
3858
|
+
|
|
3859
|
+
report(f"{label} scenario passed.")
|
|
3860
|
+
return 0
|
|
3861
|
+
|
|
3862
|
+
|
|
3863
|
+
def validate_durable_owner_vocabulary_scenario() -> int:
|
|
3864
|
+
label = "durable-owner-vocabulary"
|
|
3865
|
+
|
|
3866
|
+
# The accepted owner forms are shape checks: a gate cannot resolve a URL or
|
|
3867
|
+
# confirm an archive path is the right one. A repo-relative path is the one
|
|
3868
|
+
# form it can actually check, so refusing it drew the line in the least
|
|
3869
|
+
# defensible place.
|
|
3870
|
+
with tempfile.TemporaryDirectory(prefix="keel-owner-vocab-") as raw_tmp:
|
|
3871
|
+
repo = Path(raw_tmp) / "repo"
|
|
3872
|
+
repo.mkdir()
|
|
3873
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
3874
|
+
write_text(repo / "openspec/FOLLOWUP.md", "# Follow-ups\n")
|
|
3875
|
+
write_text(repo / "keel/HANDOFF.md", "pointer\n")
|
|
3876
|
+
write_text(repo / "keel/archive/notes/2026-07-28-example.md", "note\n")
|
|
3877
|
+
|
|
3878
|
+
def invalidation_start(closure: str) -> dict:
|
|
3879
|
+
write_text(
|
|
3880
|
+
tasks_path,
|
|
3881
|
+
task_contract_fixture().replace(
|
|
3882
|
+
"## Invalidates\n\n- None.\n\n",
|
|
3883
|
+
'## Invalidates\n\n- I1: "the wording that is now wrong" '
|
|
3884
|
+
f"— somewhere in the repo. {closure}\n\n",
|
|
3885
|
+
),
|
|
3886
|
+
)
|
|
3887
|
+
result = run_keel(
|
|
3888
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
3889
|
+
)
|
|
3890
|
+
return json.loads(result.stdout)
|
|
3891
|
+
|
|
3892
|
+
def completion(findings: str) -> dict:
|
|
3893
|
+
fixture = (
|
|
3894
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
3895
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
3896
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
3897
|
+
.replace(
|
|
3898
|
+
" - Acceptance check: pending\n",
|
|
3899
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
3900
|
+
)
|
|
3901
|
+
.replace(
|
|
3902
|
+
" - Scope check: pending\n",
|
|
3903
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
3904
|
+
)
|
|
3905
|
+
.replace(
|
|
3906
|
+
" - Findings: pending\n", f" - Findings: {findings}\n"
|
|
3907
|
+
)
|
|
3908
|
+
)
|
|
3909
|
+
write_text(tasks_path, fixture)
|
|
3910
|
+
result = run_keel(
|
|
3911
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
3912
|
+
)
|
|
3913
|
+
return json.loads(result.stdout)
|
|
3914
|
+
|
|
3915
|
+
def close(closure: str) -> dict:
|
|
3916
|
+
write_text(
|
|
3917
|
+
tasks_path,
|
|
3918
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
3919
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
3920
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
3921
|
+
.replace(
|
|
3922
|
+
" - Acceptance check: pending\n",
|
|
3923
|
+
" - Acceptance check: proven.\n",
|
|
3924
|
+
)
|
|
3925
|
+
.replace(" - Scope check: pending\n", " - Scope check: inside Touch.\n")
|
|
3926
|
+
.replace(" - Findings: pending\n", " - Findings: none\n")
|
|
3927
|
+
+ f"\n## Expectation Coverage\n\n- E1: the expectation. {closure}\n",
|
|
3928
|
+
)
|
|
3929
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
3930
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
3931
|
+
write_text(
|
|
3932
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
3933
|
+
"## ADDED Requirements\n",
|
|
3934
|
+
)
|
|
3935
|
+
result = run_keel(
|
|
3936
|
+
repo, "gate", "change-close", "--change", "demo", "--action", "sync", "--json"
|
|
3937
|
+
)
|
|
3938
|
+
return json.loads(result.stdout)
|
|
3939
|
+
|
|
3940
|
+
# M1 — an existing repo path closes an entry in all three shared places.
|
|
3941
|
+
ledger = "Durable owner: openspec/FOLLOWUP.md"
|
|
3942
|
+
payload = invalidation_start(ledger)
|
|
3943
|
+
if payload.get("status") != "pass":
|
|
3944
|
+
report(f"{label} refused a repo ledger as an invalidation owner.")
|
|
3945
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3946
|
+
return 1
|
|
3947
|
+
|
|
3948
|
+
payload = completion(f"the IDE shell contract gap. {ledger}")
|
|
3949
|
+
if payload.get("status") != "pass":
|
|
3950
|
+
report(f"{label} refused a repo ledger as a Findings owner.")
|
|
3951
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3952
|
+
return 1
|
|
3953
|
+
|
|
3954
|
+
payload = close(ledger)
|
|
3955
|
+
if any(
|
|
3956
|
+
item.get("code") == "expectation-closure"
|
|
3957
|
+
for item in payload.get("problems", [])
|
|
3958
|
+
):
|
|
3959
|
+
report(f"{label} refused a repo ledger as an Expectation Coverage owner.")
|
|
3960
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3961
|
+
return 1
|
|
3962
|
+
|
|
3963
|
+
# And a path with no file behind it is refused, distinguishably.
|
|
3964
|
+
payload = invalidation_start("Durable owner: openspec/NOT-THERE.md")
|
|
3965
|
+
codes = {item.get("code") for item in payload.get("problems", [])}
|
|
3966
|
+
messages = " ".join(
|
|
3967
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3968
|
+
)
|
|
3969
|
+
if (
|
|
3970
|
+
payload.get("status") != "fail"
|
|
3971
|
+
or "invalidation-owner-missing" not in codes
|
|
3972
|
+
or "openspec/NOT-THERE.md" not in messages
|
|
3973
|
+
):
|
|
3974
|
+
report(f"{label} accepted a durable owner with no file behind it.")
|
|
3975
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3976
|
+
return 1
|
|
3977
|
+
|
|
3978
|
+
# M2 — the pointer override is still not an owner, although it exists.
|
|
3979
|
+
payload = invalidation_start("Durable owner: keel/HANDOFF.md")
|
|
3980
|
+
messages = " ".join(
|
|
3981
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3982
|
+
)
|
|
3983
|
+
if payload.get("status") != "fail" or "HANDOFF" not in messages:
|
|
3984
|
+
report(f"{label} accepted keel/HANDOFF.md as a durable owner.")
|
|
3985
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3986
|
+
return 1
|
|
3987
|
+
|
|
3988
|
+
# A refusal names the forms it accepts, including the new one.
|
|
3989
|
+
payload = invalidation_start("no closure at all")
|
|
3990
|
+
messages = " ".join(
|
|
3991
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3992
|
+
)
|
|
3993
|
+
for expected in ("Durable owner:", "repo-relative path that exists", "Discard reason:"):
|
|
3994
|
+
if expected not in messages:
|
|
3995
|
+
report(f"{label} refusal does not name the accepted form: {expected}")
|
|
3996
|
+
report(messages)
|
|
3997
|
+
return 1
|
|
3998
|
+
|
|
3999
|
+
# M3 — every previously accepted form still closes.
|
|
4000
|
+
for closure in (
|
|
4001
|
+
"Durable owner: openspec/changes/demo/proposal.md",
|
|
4002
|
+
"Durable owner: keel/archive/notes/2026-07-28-example.md",
|
|
4003
|
+
"Durable owner: https://github.com/TanglmChris/keel/issues/20",
|
|
4004
|
+
"Discard reason: it stands as written.",
|
|
4005
|
+
):
|
|
4006
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
4007
|
+
payload = invalidation_start(closure)
|
|
4008
|
+
if payload.get("status") != "pass":
|
|
4009
|
+
report(f"{label} dropped a previously accepted form: {closure}")
|
|
4010
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
4011
|
+
return 1
|
|
4012
|
+
|
|
4013
|
+
report(f"{label} scenario passed.")
|
|
4014
|
+
return 0
|
|
4015
|
+
|
|
4016
|
+
|
|
4017
|
+
def regression_tag_fixture(
|
|
4018
|
+
commands: tuple[str, ...],
|
|
4019
|
+
evidence: tuple[str, ...],
|
|
4020
|
+
*,
|
|
4021
|
+
strategy: str = "vertical-tdd",
|
|
4022
|
+
) -> str:
|
|
4023
|
+
return (
|
|
4024
|
+
task_contract_fixture(commands=commands, evidence=evidence)
|
|
4025
|
+
.replace(
|
|
4026
|
+
" - Commands:\n",
|
|
4027
|
+
f" - Verification Strategy: {strategy}\n - Commands:\n",
|
|
4028
|
+
)
|
|
4029
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
4030
|
+
.replace(
|
|
4031
|
+
" - Acceptance check: pending\n",
|
|
4032
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
4033
|
+
)
|
|
4034
|
+
.replace(
|
|
4035
|
+
" - Scope check: pending\n",
|
|
4036
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
4037
|
+
)
|
|
4038
|
+
.replace(" - Findings: pending\n", " - Findings: none\n")
|
|
4039
|
+
)
|
|
4040
|
+
|
|
4041
|
+
|
|
4042
|
+
def validate_regression_check_tag_scenario() -> int:
|
|
4043
|
+
label = "regression-check-tag"
|
|
4044
|
+
|
|
4045
|
+
# A regression check asserts that something already green is still green, so
|
|
4046
|
+
# it has no honest red. Requiring one leaves an author fabricating evidence
|
|
4047
|
+
# or folding the guard into the behavior check; the tag is the third option.
|
|
4048
|
+
with tempfile.TemporaryDirectory(prefix="keel-regression-tag-") as raw_tmp:
|
|
4049
|
+
repo = Path(raw_tmp) / "repo"
|
|
4050
|
+
repo.mkdir()
|
|
4051
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
4052
|
+
|
|
4053
|
+
def complete(fixture: str) -> dict:
|
|
4054
|
+
write_text(tasks_path, fixture)
|
|
4055
|
+
result = run_keel(
|
|
4056
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
4057
|
+
)
|
|
4058
|
+
return json.loads(result.stdout)
|
|
4059
|
+
|
|
4060
|
+
def start(fixture: str) -> dict:
|
|
4061
|
+
write_text(tasks_path, fixture)
|
|
4062
|
+
result = run_keel(
|
|
4063
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
4064
|
+
)
|
|
4065
|
+
return json.loads(result.stdout)
|
|
4066
|
+
|
|
4067
|
+
mixed_commands = (
|
|
4068
|
+
"M1: behavior reaches the public interface",
|
|
4069
|
+
"M2 (regression): the existing suite stays green",
|
|
4070
|
+
)
|
|
4071
|
+
|
|
4072
|
+
# M1 — a tagged check completes without red/green, and the untagged one
|
|
4073
|
+
# still needs both.
|
|
4074
|
+
payload = complete(
|
|
4075
|
+
regression_tag_fixture(
|
|
4076
|
+
mixed_commands,
|
|
4077
|
+
(
|
|
4078
|
+
"M1: behavior exercised.",
|
|
4079
|
+
"M1.red: failed before the implementation.",
|
|
4080
|
+
"M1.green: passed after.",
|
|
4081
|
+
"M2: existing suite still green.",
|
|
4082
|
+
),
|
|
4083
|
+
)
|
|
4084
|
+
)
|
|
4085
|
+
if payload.get("status") != "pass":
|
|
4086
|
+
report(f"{label} refused a tagged regression check that needs no red.")
|
|
4087
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
4088
|
+
return 1
|
|
4089
|
+
|
|
4090
|
+
# D5 — the exemption is from red-green, not from evidence.
|
|
4091
|
+
payload = complete(
|
|
4092
|
+
regression_tag_fixture(
|
|
4093
|
+
mixed_commands,
|
|
4094
|
+
(
|
|
4095
|
+
"M1: behavior exercised.",
|
|
4096
|
+
"M1.red: failed before the implementation.",
|
|
4097
|
+
"M1.green: passed after.",
|
|
4098
|
+
"M2: pending",
|
|
4099
|
+
),
|
|
4100
|
+
)
|
|
4101
|
+
)
|
|
4102
|
+
if payload.get("status") != "fail" or not any(
|
|
4103
|
+
"M2" in item.get("message", "")
|
|
4104
|
+
for item in payload.get("problems", [])
|
|
4105
|
+
):
|
|
4106
|
+
report(f"{label} completed a tagged check with no evidence at all.")
|
|
4107
|
+
report(json.dumps(payload, indent=2))
|
|
4108
|
+
return 1
|
|
4109
|
+
|
|
4110
|
+
# M2 — the strategy cannot be emptied out by tagging every check.
|
|
4111
|
+
payload = start(
|
|
4112
|
+
regression_tag_fixture(
|
|
4113
|
+
(
|
|
4114
|
+
"M1 (regression): the existing suite stays green",
|
|
4115
|
+
"M2 (regression): the golden files stay byte-identical",
|
|
4116
|
+
),
|
|
4117
|
+
("M1: pending", "M2: pending"),
|
|
4118
|
+
)
|
|
4119
|
+
)
|
|
4120
|
+
codes = {item.get("code") for item in payload.get("problems", [])}
|
|
4121
|
+
if payload.get("status") != "fail" or "regression-only-strategy" not in codes:
|
|
4122
|
+
report(
|
|
4123
|
+
f"{label} accepted a red-green strategy whose every check is tagged."
|
|
4124
|
+
)
|
|
4125
|
+
report(json.dumps(payload, indent=2))
|
|
4126
|
+
return 1
|
|
4127
|
+
|
|
4128
|
+
# M3 — an untagged check emits no tag key, so its capsule and fingerprint
|
|
4129
|
+
# are byte-identical to what they were before the tag existed.
|
|
4130
|
+
payload = start(
|
|
4131
|
+
regression_tag_fixture(
|
|
4132
|
+
("M1: behavior reaches the public interface",),
|
|
4133
|
+
("M1: pending",),
|
|
4134
|
+
)
|
|
4135
|
+
)
|
|
4136
|
+
entries = (
|
|
4137
|
+
payload.get("contract", {})
|
|
4138
|
+
.get("capsule", {})
|
|
4139
|
+
.get("verification", {})
|
|
4140
|
+
.get("commands", [])
|
|
4141
|
+
)
|
|
4142
|
+
if payload.get("status") != "pass" or [sorted(entry) for entry in entries] != [
|
|
4143
|
+
["check", "label"]
|
|
4144
|
+
]:
|
|
4145
|
+
report(
|
|
4146
|
+
f"{label} changed the compiled shape of an untagged check, which "
|
|
4147
|
+
"moves every recorded contract fingerprint."
|
|
4148
|
+
)
|
|
4149
|
+
report(json.dumps(entries, indent=2))
|
|
4150
|
+
return 1
|
|
4151
|
+
|
|
4152
|
+
# And a tagged check does declare itself in the capsule, so the exemption
|
|
4153
|
+
# is a visible term of the contract rather than a silent skip.
|
|
4154
|
+
payload = start(
|
|
4155
|
+
regression_tag_fixture(mixed_commands, ("M1: pending", "M2: pending"))
|
|
4156
|
+
)
|
|
4157
|
+
tagged = next(
|
|
4158
|
+
(
|
|
4159
|
+
entry
|
|
4160
|
+
for entry in payload.get("contract", {})
|
|
4161
|
+
.get("capsule", {})
|
|
4162
|
+
.get("verification", {})
|
|
4163
|
+
.get("commands", [])
|
|
4164
|
+
if entry.get("label") == "M2"
|
|
4165
|
+
),
|
|
4166
|
+
None,
|
|
4167
|
+
)
|
|
4168
|
+
if not tagged or tagged.get("regression") is not True:
|
|
4169
|
+
report(f"{label} did not record the regression tag in the capsule.")
|
|
4170
|
+
report(json.dumps(payload.get("contract", {}), indent=2))
|
|
4171
|
+
return 1
|
|
4172
|
+
|
|
4173
|
+
report(f"{label} scenario passed.")
|
|
4174
|
+
return 0
|
|
4175
|
+
|
|
4176
|
+
|
|
4177
|
+
def validate_packaged_schema_derivation_scenario() -> int:
|
|
4178
|
+
label = "packaged-schema-derivation"
|
|
4179
|
+
|
|
4180
|
+
# The helper derives the consumer-repo paths every install/uninstall/clear
|
|
4181
|
+
# assertion iterates. When its root stopped existing it returned an empty
|
|
4182
|
+
# list, so those loops compared nothing and reported success. Anchor it to
|
|
4183
|
+
# what the installer really writes, and make emptiness a failure here.
|
|
4184
|
+
try:
|
|
4185
|
+
packaged_openspec_schema_install_paths(ROOT / "no-such-packaged-root")
|
|
4186
|
+
except FileNotFoundError as error:
|
|
4187
|
+
if "no-such-packaged-root" not in str(error):
|
|
4188
|
+
report(f"{label} missing-root failure does not name the path it expected.")
|
|
4189
|
+
report(str(error))
|
|
4190
|
+
return 1
|
|
4191
|
+
else:
|
|
4192
|
+
report(
|
|
4193
|
+
f"{label} returned a set for a missing packaged root instead of failing; "
|
|
4194
|
+
"an absent root must not silently empty its callers' assertions."
|
|
4195
|
+
)
|
|
4196
|
+
return 1
|
|
4197
|
+
|
|
4198
|
+
derived = packaged_openspec_schema_install_paths()
|
|
4199
|
+
if not derived:
|
|
4200
|
+
report(
|
|
4201
|
+
f"{label} derived no packaged schema paths, so every assertion that "
|
|
4202
|
+
"iterates them verifies nothing."
|
|
4203
|
+
)
|
|
4204
|
+
return 1
|
|
4205
|
+
|
|
4206
|
+
with tempfile.TemporaryDirectory(prefix="keel-packaged-schema-") as raw_tmp:
|
|
4207
|
+
repo = Path(raw_tmp) / "repo"
|
|
4208
|
+
repo.mkdir()
|
|
4209
|
+
install = run_keel(repo, "--install")
|
|
4210
|
+
if install.returncode != 0:
|
|
4211
|
+
report(f"{label} keel --install failed.")
|
|
4212
|
+
report((install.stderr or install.stdout).strip())
|
|
4213
|
+
return 1
|
|
4214
|
+
|
|
4215
|
+
schema_root = repo / OPENSPEC_SCHEMA_ROOT
|
|
4216
|
+
installed = sorted(
|
|
4217
|
+
path.relative_to(repo).as_posix()
|
|
4218
|
+
for path in schema_root.rglob("*")
|
|
4219
|
+
if path.is_file()
|
|
4220
|
+
)
|
|
4221
|
+
if installed != sorted(derived):
|
|
4222
|
+
report(
|
|
4223
|
+
f"{label} derived paths do not match what keel --install wrote."
|
|
4224
|
+
)
|
|
4225
|
+
report(f"derived: {sorted(derived)}")
|
|
4226
|
+
report(f"installed: {installed}")
|
|
4227
|
+
return 1
|
|
4228
|
+
|
|
4229
|
+
report(f"{label} scenario passed.")
|
|
4230
|
+
return 0
|
|
4231
|
+
|
|
4232
|
+
|
|
3645
4233
|
def validate_invalidation_authoring_surface_scenario() -> int:
|
|
3646
4234
|
label = "invalidation-authoring-surface"
|
|
3647
4235
|
|
|
3648
4236
|
# The two schema copies are the repo-local one OpenSpec resolves and the
|
|
3649
|
-
# packaged one `keel --init` writes.
|
|
3650
|
-
#
|
|
3651
|
-
#
|
|
4237
|
+
# packaged one `keel --init` writes. This is the only check that asserts they
|
|
4238
|
+
# agree: compact-task-authoring used to imply it through a projection loop
|
|
4239
|
+
# rooted at trees that no longer exist, so it compared nothing and has since
|
|
4240
|
+
# been removed.
|
|
3652
4241
|
for local, packaged in SCHEMA_COPY_PAIRS:
|
|
3653
4242
|
local_text = (ROOT / local).read_text(encoding="utf-8")
|
|
3654
4243
|
packaged_text = (ROOT / packaged).read_text(encoding="utf-8")
|
|
@@ -4449,29 +5038,56 @@ def validate_source_repo_bootstrap_skip_scenario() -> int:
|
|
|
4449
5038
|
found = managed.search(path.read_text(encoding="utf-8"))
|
|
4450
5039
|
return found.group(0) if found else ""
|
|
4451
5040
|
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
5041
|
+
# `is_keel_source_repo` reads exactly two signals — the package name and a
|
|
5042
|
+
# plugins/keel directory — so a fixture carrying both exercises the same
|
|
5043
|
+
# branch. Running this against the real repository used to work and used to
|
|
5044
|
+
# rewrite the .claude/ overlay markers as a side effect, which is how the
|
|
5045
|
+
# marker check ended up green on that side for the wrong reason.
|
|
5046
|
+
if not (ROOT / "AGENTS.md").is_file() or not block(ROOT / "AGENTS.md"):
|
|
4455
5047
|
report("source-repo-bootstrap-skip: Keel's AGENTS.md has no managed block.")
|
|
4456
5048
|
return 1
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
)
|
|
4473
|
-
|
|
4474
|
-
|
|
5049
|
+
|
|
5050
|
+
with tempfile.TemporaryDirectory(prefix="keel-source-repo-") as raw:
|
|
5051
|
+
fixture = Path(raw) / "keel"
|
|
5052
|
+
write_text(fixture / "package.json", json.dumps({"name": KEEL_PACKAGE_NAME}))
|
|
5053
|
+
write_text(fixture / "plugins/keel/.keep", "")
|
|
5054
|
+
own_agents = fixture / "AGENTS.md"
|
|
5055
|
+
write_text(own_agents, (ROOT / "AGENTS.md").read_text(encoding="utf-8"))
|
|
5056
|
+
before_tree = snapshot_files(fixture)
|
|
5057
|
+
before = block(own_agents)
|
|
5058
|
+
|
|
5059
|
+
result = run_keel(fixture, "--install", "--target", "claude")
|
|
5060
|
+
if result.returncode != 0:
|
|
5061
|
+
report("source-repo-bootstrap-skip: keel --install failed in Keel's repo.")
|
|
5062
|
+
report((result.stderr or result.stdout).strip())
|
|
5063
|
+
return 1
|
|
5064
|
+
if block(own_agents) != before:
|
|
5065
|
+
report(
|
|
5066
|
+
"source-repo-bootstrap-skip: keel --install rewrote Keel's own "
|
|
5067
|
+
"AGENTS.md managed block."
|
|
5068
|
+
)
|
|
5069
|
+
return 1
|
|
5070
|
+
if "skip AGENTS.md" not in (result.stdout or ""):
|
|
5071
|
+
report(
|
|
5072
|
+
"source-repo-bootstrap-skip: the skip was silent; it must be "
|
|
5073
|
+
"reported explicitly."
|
|
5074
|
+
)
|
|
5075
|
+
report((result.stdout or "").strip())
|
|
5076
|
+
return 1
|
|
5077
|
+
# The original defect was the missing assertion, not only the wrong
|
|
5078
|
+
# repository: name what the install must not have rewritten.
|
|
5079
|
+
rewritten = [
|
|
5080
|
+
name
|
|
5081
|
+
for name, text in before_tree.items()
|
|
5082
|
+
if (fixture / name).is_file()
|
|
5083
|
+
and (fixture / name).read_text(encoding="utf-8") != text
|
|
5084
|
+
]
|
|
5085
|
+
if rewritten:
|
|
5086
|
+
report(
|
|
5087
|
+
"source-repo-bootstrap-skip: keel --install rewrote files it "
|
|
5088
|
+
"did not announce: " + ", ".join(sorted(rewritten))
|
|
5089
|
+
)
|
|
5090
|
+
return 1
|
|
4475
5091
|
# A consuming project must still receive the bootstrap.
|
|
4476
5092
|
with tempfile.TemporaryDirectory(prefix="keel-bootstrap-consumer-") as raw:
|
|
4477
5093
|
consumer = Path(raw)
|
|
@@ -4611,6 +5227,9 @@ def validate_tracker_durable_owner_scenario() -> int:
|
|
|
4611
5227
|
report((handoff.stderr or handoff.stdout).strip())
|
|
4612
5228
|
return 1
|
|
4613
5229
|
|
|
5230
|
+
# The archive path must now exist to own anything: a note nobody wrote
|
|
5231
|
+
# owns nothing, and a path is the one owner form a gate can check.
|
|
5232
|
+
write_text(repo / "keel/archive/follow-ups/x.md", "follow-up note\n")
|
|
4614
5233
|
archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
|
|
4615
5234
|
if archived.returncode != 0:
|
|
4616
5235
|
report(
|
|
@@ -7661,13 +8280,7 @@ def validate_expectation_alignment_real_tasks_scenario() -> int:
|
|
|
7661
8280
|
|
|
7662
8281
|
|
|
7663
8282
|
def validate_compact_task_authoring_scenario() -> int:
|
|
7664
|
-
source_root = (
|
|
7665
|
-
ROOT / "src" / "assets" / "shared" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7666
|
-
)
|
|
7667
8283
|
local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7668
|
-
dist_root = (
|
|
7669
|
-
ROOT / "dist" / "shared" / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7670
|
-
)
|
|
7671
8284
|
|
|
7672
8285
|
which = run_openspec(ROOT, "schema", "which", OPENSPEC_SCHEMA_NAME, "--json")
|
|
7673
8286
|
if which is None or which.returncode != 0:
|
|
@@ -7726,21 +8339,9 @@ def validate_compact_task_authoring_scenario() -> int:
|
|
|
7726
8339
|
)
|
|
7727
8340
|
return 1
|
|
7728
8341
|
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
continue
|
|
7733
|
-
relative = source_file.relative_to(source_root)
|
|
7734
|
-
projected = projection_root / relative
|
|
7735
|
-
if not projected.is_file() or (
|
|
7736
|
-
source_file.read_text(encoding="utf-8")
|
|
7737
|
-
!= projected.read_text(encoding="utf-8")
|
|
7738
|
-
):
|
|
7739
|
-
report(
|
|
7740
|
-
f"compact-task-authoring {label} projection diverges from "
|
|
7741
|
-
f"canonical source: {relative.as_posix()}"
|
|
7742
|
-
)
|
|
7743
|
-
return 1
|
|
8342
|
+
# The projection loop that stood here compared against `src/assets` and
|
|
8343
|
+
# `dist`, both retired, so it iterated nothing. `invalidation-authoring-surface`
|
|
8344
|
+
# asserts the two copies that do exist are byte-identical.
|
|
7744
8345
|
|
|
7745
8346
|
with tempfile.TemporaryDirectory(prefix="keel-compact-") as raw_tmp:
|
|
7746
8347
|
repo = Path(raw_tmp) / "fixture"
|
|
@@ -10287,6 +10888,20 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
10287
10888
|
)
|
|
10288
10889
|
return 1
|
|
10289
10890
|
|
|
10891
|
+
# The bootstrap is the whole resident protocol a consumer gets, and the
|
|
10892
|
+
# qualifier "for product files" left readers to infer what it excluded.
|
|
10893
|
+
# The inference actually made was that tasks.md belongs in Touch.
|
|
10894
|
+
if not re.search(r"Touch\b[^\n]*\bbound", block, re.IGNORECASE):
|
|
10895
|
+
report("thin-native-install bootstrap does not state what Touch bounds.")
|
|
10896
|
+
return 1
|
|
10897
|
+
if not re.search(r"change'?s own dir|own change dir", block, re.IGNORECASE):
|
|
10898
|
+
report(
|
|
10899
|
+
"thin-native-install bootstrap does not name the record-write "
|
|
10900
|
+
"exemption, so a consumer still infers that tasks.md belongs in "
|
|
10901
|
+
"Touch."
|
|
10902
|
+
)
|
|
10903
|
+
return 1
|
|
10904
|
+
|
|
10290
10905
|
claude_text = (repo / "CLAUDE.md").read_text(encoding="utf-8")
|
|
10291
10906
|
if claude_text.count("@AGENTS.md") != 1:
|
|
10292
10907
|
report(
|
|
@@ -11006,7 +11621,7 @@ def validate_resident_topic_matching_scenario() -> int:
|
|
|
11006
11621
|
"""
|
|
11007
11622
|
label = "resident-topic-matching"
|
|
11008
11623
|
source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
11009
|
-
original = "Touch
|
|
11624
|
+
original = "Touch bounds product writes; the change's own dir is exempt."
|
|
11010
11625
|
if original not in source:
|
|
11011
11626
|
report(
|
|
11012
11627
|
f"{label}: the fixture's anchor sentence is not in the bootstrap; "
|
|
@@ -12073,7 +12688,11 @@ def validate_touch_guard_surface_scenario() -> int:
|
|
|
12073
12688
|
report(f"touch-guard-surface: README lacks guard guidance: {needle}.")
|
|
12074
12689
|
return 1
|
|
12075
12690
|
bootstrap = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
12076
|
-
|
|
12691
|
+
# The bootstrap must tell a consumer the guard exists and how to opt out;
|
|
12692
|
+
# it no longer spends bytes naming `keel guard clear`, which `keel --help`
|
|
12693
|
+
# and `keel guard status` carry. `--no-guard` is the flag it does name, so
|
|
12694
|
+
# that one stays literal and a rename of it still fails here.
|
|
12695
|
+
if "--no-guard" not in bootstrap or "guards it by default" not in bootstrap:
|
|
12077
12696
|
report("touch-guard-surface: bootstrap does not mention the guard.")
|
|
12078
12697
|
return 1
|
|
12079
12698
|
registered = {name for name, _ in SCENARIOS}
|
|
@@ -12452,6 +13071,17 @@ SCENARIOS: tuple = (
|
|
|
12452
13071
|
("runner-skip-accounting", validate_runner_skip_accounting_scenario),
|
|
12453
13072
|
("resident-topic-matching", validate_resident_topic_matching_scenario),
|
|
12454
13073
|
("task-start-invalidation", validate_task_start_invalidation_scenario),
|
|
13074
|
+
("regression-check-tag", validate_regression_check_tag_scenario),
|
|
13075
|
+
("durable-owner-vocabulary", validate_durable_owner_vocabulary_scenario),
|
|
13076
|
+
("anchor-reverification-bound", validate_anchor_reverification_bound_scenario),
|
|
13077
|
+
(
|
|
13078
|
+
"authoring-surface-owner-and-tags",
|
|
13079
|
+
validate_authoring_surface_owner_and_tags_scenario,
|
|
13080
|
+
),
|
|
13081
|
+
(
|
|
13082
|
+
"packaged-schema-derivation",
|
|
13083
|
+
validate_packaged_schema_derivation_scenario,
|
|
13084
|
+
),
|
|
12455
13085
|
(
|
|
12456
13086
|
"invalidation-authoring-surface",
|
|
12457
13087
|
validate_invalidation_authoring_surface_scenario,
|